From bfa5c21657983be14b00fcf17f791d3ff4a7d79a Mon Sep 17 00:00:00 2001 From: Matt Krick Date: Wed, 29 May 2024 15:38:42 -0700 Subject: [PATCH 01/47] chore: read ReflectionGroups from PG Signed-off-by: Matt Krick --- codegen.json | 2 +- .../modules/demo/ClientGraphQLServer.ts | 12 +++- packages/server/database/rethinkDriver.ts | 5 -- .../dataloader/foreignKeyLoaderMakers.ts | 14 +++++ .../dataloader/primaryKeyLoaderMakers.ts | 4 ++ .../rethinkForeignKeyLoaderMakers.ts | 13 ----- .../rethinkPrimaryKeyLoaderMakers.ts | 1 - .../graphql/mutations/createReflection.ts | 1 - .../helpers/generateGroupSummaries.ts | 19 ++----- .../mutations/helpers/handleCompletedStage.ts | 21 ++----- .../helpers/notifications/SlackNotifier.ts | 2 +- .../helpers/removeEmptyReflectionGroup.ts | 18 ++---- .../helpers/removeEmptyReflections.ts | 9 +-- .../mutations/helpers/safeEndRetrospective.ts | 6 -- .../mutations/helpers/safelyCastVote.ts | 44 +++++--------- .../mutations/helpers/safelyWithdrawVote.ts | 38 +++---------- .../addReflectionToGroup.ts | 57 ++++++------------- .../removeReflectionFromGroup.ts | 38 +++++-------- .../updateSmartGroupTitle.ts | 31 +++------- .../resetRetroMeetingToGroupStage.ts | 5 -- .../mutations/updateReflectionGroupTitle.ts | 32 ++++------- .../mutations/voteForReflectionGroup.ts | 3 +- .../private/mutations/backupOrganization.ts | 17 ------ .../mutations/checkRethinkPgEquality.ts | 2 +- .../public/types/RetrospectiveMeeting.ts | 15 ++--- 25 files changed, 124 insertions(+), 285 deletions(-) diff --git a/codegen.json b/codegen.json index 83f9993f249..09b47622b08 100644 --- a/codegen.json +++ b/codegen.json @@ -106,7 +106,7 @@ "RequestToJoinDomainSuccess": "./types/RequestToJoinDomainSuccess#RequestToJoinDomainSuccessSource", "ResetReflectionGroupsSuccess": "./types/ResetReflectionGroupsSuccess#ResetReflectionGroupsSuccessSource", "RetroReflection": "../../database/types/RetroReflection#default as RetroReflectionDB", - "RetroReflectionGroup": "../../database/types/RetroReflectionGroup#default as RetroReflectionGroupDB", + "RetroReflectionGroup": "../../postgres/pg.d#RetroReflectionGroup as RetroReflectionGroupDB", "RetrospectiveMeeting": "../../database/types/MeetingRetrospective#default", "RetrospectiveMeetingMember": "../../database/types/RetroMeetingMember#default", "RetrospectiveMeetingSettings": "../../database/types/MeetingSettingsRetrospective#default", diff --git a/packages/client/modules/demo/ClientGraphQLServer.ts b/packages/client/modules/demo/ClientGraphQLServer.ts index e58c5f10f22..dd8adfd039f 100644 --- a/packages/client/modules/demo/ClientGraphQLServer.ts +++ b/packages/client/modules/demo/ClientGraphQLServer.ts @@ -15,7 +15,6 @@ import NewMeetingStage from '../../../server/database/types/GenericMeetingStage' import GoogleAnalyzedEntity from '../../../server/database/types/GoogleAnalyzedEntity' import ReflectPhase from '../../../server/database/types/ReflectPhase' import Reflection from '../../../server/database/types/Reflection' -import ReflectionGroup from '../../../server/database/types/ReflectionGroup' import ITask from '../../../server/database/types/Task' import { ExternalLinks, @@ -69,8 +68,17 @@ export type DemoReflection = Omit & { +export type DemoReflectionGroup = { __typename: string + id: string + isActive: boolean + meetingId: string + promptId: string + sortOrder: number + smartTitle: string | null + summary: string | null + title: string | null + discussionPromptQuestion: string | null commentors: any createdAt: string | Date meeting: any diff --git a/packages/server/database/rethinkDriver.ts b/packages/server/database/rethinkDriver.ts index e030375d971..40621c4db4c 100644 --- a/packages/server/database/rethinkDriver.ts +++ b/packages/server/database/rethinkDriver.ts @@ -30,7 +30,6 @@ import OrganizationUser from './types/OrganizationUser' import PasswordResetRequest from './types/PasswordResetRequest' import PushInvitation from './types/PushInvitation' import Reflection from './types/Reflection' -import ReflectionGroup from './types/ReflectionGroup' import RetrospectivePrompt from './types/RetrospectivePrompt' import SAML from './types/SAML' import SuggestedActionCreateNewTeam from './types/SuggestedActionCreateNewTeam' @@ -143,10 +142,6 @@ export type RethinkSchema = { type: MeetingTemplate index: 'teamId' | 'orgId' } - RetroReflectionGroup: { - type: ReflectionGroup - index: 'meetingId' - } RetroReflection: { type: Reflection index: 'meetingId' | 'reflectionGroupId' diff --git a/packages/server/dataloader/foreignKeyLoaderMakers.ts b/packages/server/dataloader/foreignKeyLoaderMakers.ts index 0c967c37b1a..8807ba06f17 100644 --- a/packages/server/dataloader/foreignKeyLoaderMakers.ts +++ b/packages/server/dataloader/foreignKeyLoaderMakers.ts @@ -23,3 +23,17 @@ export const embeddingsMetadataByRefId = foreignKeyLoaderMaker( return pg.selectFrom('EmbeddingsMetadata').selectAll().where('refId', 'in', refId).execute() } ) + +export const retroReflectionGroupsByMeetingId = foreignKeyLoaderMaker( + 'retroReflectionGroups', + 'meetingId', + async (meetingIds) => { + const pg = getKysely() + return pg + .selectFrom('RetroReflectionGroup') + .selectAll() + .where('meetingId', 'in', meetingIds) + .where('isActive', '=', true) + .execute() + } +) diff --git a/packages/server/dataloader/primaryKeyLoaderMakers.ts b/packages/server/dataloader/primaryKeyLoaderMakers.ts index 09f2b463024..6ea90c9dd15 100644 --- a/packages/server/dataloader/primaryKeyLoaderMakers.ts +++ b/packages/server/dataloader/primaryKeyLoaderMakers.ts @@ -25,3 +25,7 @@ export const kudoses = primaryKeyLoaderMaker(getKudosesByIds) export const embeddingsMetadata = primaryKeyLoaderMaker((ids: readonly number[]) => { return getKysely().selectFrom('EmbeddingsMetadata').selectAll().where('id', 'in', ids).execute() }) + +export const retroReflectionGroups = primaryKeyLoaderMaker((ids: readonly string[]) => { + return getKysely().selectFrom('RetroReflectionGroup').selectAll().where('id', 'in', ids).execute() +}) diff --git a/packages/server/dataloader/rethinkForeignKeyLoaderMakers.ts b/packages/server/dataloader/rethinkForeignKeyLoaderMakers.ts index 70a038b712c..34ad30aca6f 100644 --- a/packages/server/dataloader/rethinkForeignKeyLoaderMakers.ts +++ b/packages/server/dataloader/rethinkForeignKeyLoaderMakers.ts @@ -152,19 +152,6 @@ export const organizationUsersByUserId = new RethinkForeignKeyLoaderMaker( } ) -export const retroReflectionGroupsByMeetingId = new RethinkForeignKeyLoaderMaker( - 'retroReflectionGroups', - 'meetingId', - async (meetingIds) => { - const r = await getRethink() - return r - .table('RetroReflectionGroup') - .getAll(r.args(meetingIds), {index: 'meetingId'}) - .filter({isActive: true}) - .run() - } -) - export const scalesByTeamId = new RethinkForeignKeyLoaderMaker( 'templateScales', 'teamId', diff --git a/packages/server/dataloader/rethinkPrimaryKeyLoaderMakers.ts b/packages/server/dataloader/rethinkPrimaryKeyLoaderMakers.ts index 1806bdd3efe..1e4f2d32e95 100644 --- a/packages/server/dataloader/rethinkPrimaryKeyLoaderMakers.ts +++ b/packages/server/dataloader/rethinkPrimaryKeyLoaderMakers.ts @@ -16,7 +16,6 @@ export const notifications = new RethinkPrimaryKeyLoaderMaker('Notification') export const organizations = new RethinkPrimaryKeyLoaderMaker('Organization') export const organizationUsers = new RethinkPrimaryKeyLoaderMaker('OrganizationUser') export const templateScales = new RethinkPrimaryKeyLoaderMaker('TemplateScale') -export const retroReflectionGroups = new RethinkPrimaryKeyLoaderMaker('RetroReflectionGroup') export const retroReflections = new RethinkPrimaryKeyLoaderMaker('RetroReflection') export const slackAuths = new RethinkPrimaryKeyLoaderMaker('SlackAuth') export const slackNotifications = new RethinkPrimaryKeyLoaderMaker('SlackNotification') diff --git a/packages/server/graphql/mutations/createReflection.ts b/packages/server/graphql/mutations/createReflection.ts index f9a7fd64760..e2c66c7501d 100644 --- a/packages/server/graphql/mutations/createReflection.ts +++ b/packages/server/graphql/mutations/createReflection.ts @@ -131,7 +131,6 @@ export default { await Promise.all([ pg.insertInto('RetroReflectionGroup').values(reflectionGroup).execute(), - r.table('RetroReflectionGroup').insert(reflectionGroup).run(), r.table('RetroReflection').insert(reflection).run() ]) diff --git a/packages/server/graphql/mutations/helpers/generateGroupSummaries.ts b/packages/server/graphql/mutations/helpers/generateGroupSummaries.ts index 3a827ca20d1..e186f016e72 100644 --- a/packages/server/graphql/mutations/helpers/generateGroupSummaries.ts +++ b/packages/server/graphql/mutations/helpers/generateGroupSummaries.ts @@ -1,4 +1,3 @@ -import getRethink from '../../../database/rethinkDriver' import getKysely from '../../../postgres/getKysely' import OpenAIServerManager from '../../../utils/OpenAIServerManager' import sendToSentry from '../../../utils/sendToSentry' @@ -26,7 +25,6 @@ const generateGroupSummaries = async ( dataLoader.get('retroReflectionsByMeetingId').load(meetingId), dataLoader.get('retroReflectionGroupsByMeetingId').load(meetingId) ]) - const r = await getRethink() const pg = getKysely() const manager = new OpenAIServerManager() if (!reflectionGroups.length) { @@ -50,18 +48,11 @@ const generateGroupSummaries = async ( if (!fullSummary && !fullQuestion) return const summary = fullSummary?.slice(0, 2000) const discussionPromptQuestion = fullQuestion?.slice(0, 2000) - return Promise.all([ - pg - .updateTable('RetroReflectionGroup') - .set({summary, discussionPromptQuestion}) - .where('id', '=', group.id) - .execute(), - r - .table('RetroReflectionGroup') - .get(group.id) - .update({summary, discussionPromptQuestion}) - .run() - ]) + return pg + .updateTable('RetroReflectionGroup') + .set({summary, discussionPromptQuestion}) + .where('id', '=', group.id) + .execute() }) ) } diff --git a/packages/server/graphql/mutations/helpers/handleCompletedStage.ts b/packages/server/graphql/mutations/helpers/handleCompletedStage.ts index 785568a6396..720a3cd5afe 100644 --- a/packages/server/graphql/mutations/helpers/handleCompletedStage.ts +++ b/packages/server/graphql/mutations/helpers/handleCompletedStage.ts @@ -35,7 +35,6 @@ const handleCompletedRetrospectiveStage = async ( if (stage.phaseType === REFLECT) { const r = await getRethink() const pg = getKysely() - const now = new Date() const [reflectionGroups, reflections] = await Promise.all([ dataLoader.get('retroReflectionGroupsByMeetingId').load(meeting.id), @@ -60,21 +59,11 @@ const handleCompletedRetrospectiveStage = async ( await Promise.all( sortedReflectionGroups.map((group, index) => { group.sortOrder = index - return Promise.all([ - pg - .updateTable('RetroReflectionGroup') - .set({sortOrder: index}) - .where('id', '=', group.id) - .execute(), - r - .table('RetroReflectionGroup') - .get(group.id) - .update({ - sortOrder: index, - updatedAt: now - } as any) - .run() - ]) + return pg + .updateTable('RetroReflectionGroup') + .set({sortOrder: index}) + .where('id', '=', group.id) + .execute() }) ) diff --git a/packages/server/graphql/mutations/helpers/notifications/SlackNotifier.ts b/packages/server/graphql/mutations/helpers/notifications/SlackNotifier.ts index 089ca295826..843ee31e4ce 100644 --- a/packages/server/graphql/mutations/helpers/notifications/SlackNotifier.ts +++ b/packages/server/graphql/mutations/helpers/notifications/SlackNotifier.ts @@ -601,7 +601,7 @@ export const SlackNotifier = { dataLoader.get('teams').loadNonNull(teamId), dataLoader.get('users').loadNonNull(userId), dataLoader.get('newMeetings').load(meetingId), - dataLoader.get('retroReflectionGroups').load(reflectionGroupId), + dataLoader.get('retroReflectionGroups').loadNonNull(reflectionGroupId), r.table('RetroReflection').getAll(reflectionGroupId, {index: 'reflectionGroupId'}).run(), r .table('SlackAuth') diff --git a/packages/server/graphql/mutations/helpers/removeEmptyReflectionGroup.ts b/packages/server/graphql/mutations/helpers/removeEmptyReflectionGroup.ts index b605ffd4bf2..7958457a558 100644 --- a/packages/server/graphql/mutations/helpers/removeEmptyReflectionGroup.ts +++ b/packages/server/graphql/mutations/helpers/removeEmptyReflectionGroup.ts @@ -7,7 +7,6 @@ const removeEmptyReflectionGroup = async ( ) => { const r = await getRethink() const pg = getKysely() - const now = new Date() if (!reflectionGroupId) return false const reflectionCount = await r .table('RetroReflection') @@ -17,18 +16,11 @@ const removeEmptyReflectionGroup = async ( .run() if (reflectionCount > 0) return - return Promise.all([ - pg - .updateTable('RetroReflectionGroup') - .set({isActive: false}) - .where('id', '=', oldReflectionGroupId) - .execute(), - r - .table('RetroReflectionGroup') - .get(oldReflectionGroupId) - .update({isActive: false, updatedAt: now}) - .run() - ]) + return pg + .updateTable('RetroReflectionGroup') + .set({isActive: false}) + .where('id', '=', oldReflectionGroupId) + .execute() } export default removeEmptyReflectionGroup diff --git a/packages/server/graphql/mutations/helpers/removeEmptyReflections.ts b/packages/server/graphql/mutations/helpers/removeEmptyReflections.ts index c3867b2f4da..1745e5d97f8 100644 --- a/packages/server/graphql/mutations/helpers/removeEmptyReflections.ts +++ b/packages/server/graphql/mutations/helpers/removeEmptyReflections.ts @@ -34,14 +34,7 @@ const removeEmptyReflections = async (meeting: Meeting) => { .updateTable('RetroReflectionGroup') .set({isActive: false}) .where('id', 'in', emptyReflectionGroupIds) - .execute(), - r - .table('RetroReflectionGroup') - .getAll(r.args(emptyReflectionGroupIds), {index: 'id'}) - .update({ - isActive: false - }) - .run() + .execute() ]) } return {emptyReflectionGroupIds} diff --git a/packages/server/graphql/mutations/helpers/safeEndRetrospective.ts b/packages/server/graphql/mutations/helpers/safeEndRetrospective.ts index 9b47bbda356..631eeebba89 100644 --- a/packages/server/graphql/mutations/helpers/safeEndRetrospective.ts +++ b/packages/server/graphql/mutations/helpers/safeEndRetrospective.ts @@ -317,12 +317,6 @@ const safeEndRetrospective = async ({ .where('meetingId', '=', meetingId) .where('isActive', '=', false) .execute(), - r - .table('RetroReflectionGroup') - .getAll(meetingId, {index: 'meetingId'}) - .filter({isActive: false}) - .delete() - .run(), updateTeamInsights(teamId, dataLoader), sendKudos(completedRetrospective, teamId, context) ]) diff --git a/packages/server/graphql/mutations/helpers/safelyCastVote.ts b/packages/server/graphql/mutations/helpers/safelyCastVote.ts index 86d2db1780d..d0e8e030ee9 100644 --- a/packages/server/graphql/mutations/helpers/safelyCastVote.ts +++ b/packages/server/graphql/mutations/helpers/safelyCastVote.ts @@ -5,7 +5,6 @@ import {RValue} from '../../../database/stricterR' import AuthToken from '../../../database/types/AuthToken' import getKysely from '../../../postgres/getKysely' import {getUserId} from '../../../utils/authorization' -import sendToSentry from '../../../utils/sendToSentry' import standardError from '../../../utils/standardError' const safelyCastVote = async ( @@ -40,36 +39,19 @@ const safelyCastVote = async ( return standardError(new Error('No votes remaining'), {userId: viewerId}) } - const [isVoteAddedToGroup, voteAddedResult] = await Promise.all([ - r - .table('RetroReflectionGroup') - .get(reflectionGroupId) - .update((group: RValue) => { - return r.branch( - group('voterIds').count(userId).lt(maxVotesPerGroup), - { - updatedAt: now, - voterIds: group('voterIds').append(userId) - }, - {} - ) - })('replaced') - .eq(1) - .run(), - pg - .updateTable('RetroReflectionGroup') - .set({voterIds: sql`ARRAY_APPEND("voterIds",${userId})`}) - .where('id', '=', reflectionGroupId) - .where( - sql`COALESCE(array_length(array_positions("voterIds", ${userId}),1),0)`, - '<', - maxVotesPerGroup - ) - .executeTakeFirst() - ]) - const isVoteAddedToGroupPG = voteAddedResult.numUpdatedRows === BigInt(1) - if (isVoteAddedToGroupPG !== isVoteAddedToGroup) - sendToSentry(new Error('MISMATCH VOTE CAST LOGIC')) + const voteAddedResult = await pg + .updateTable('RetroReflectionGroup') + .set({voterIds: sql`ARRAY_APPEND("voterIds",${userId})`}) + .where('id', '=', reflectionGroupId) + .where( + sql`COALESCE(array_length(array_positions("voterIds", ${userId}),1),0)`, + '<', + maxVotesPerGroup + ) + .executeTakeFirst() + + const isVoteAddedToGroup = voteAddedResult.numUpdatedRows === BigInt(1) + if (!isVoteAddedToGroup) { await r .table('MeetingMember') diff --git a/packages/server/graphql/mutations/helpers/safelyWithdrawVote.ts b/packages/server/graphql/mutations/helpers/safelyWithdrawVote.ts index 939e0519dcd..b78559ee901 100644 --- a/packages/server/graphql/mutations/helpers/safelyWithdrawVote.ts +++ b/packages/server/graphql/mutations/helpers/safelyWithdrawVote.ts @@ -5,7 +5,6 @@ import {RValue} from '../../../database/stricterR' import AuthToken from '../../../database/types/AuthToken' import getKysely from '../../../postgres/getKysely' import {getUserId} from '../../../utils/authorization' -import sendToSentry from '../../../utils/sendToSentry' import standardError from '../../../utils/standardError' const safelyWithdrawVote = async ( @@ -19,37 +18,18 @@ const safelyWithdrawVote = async ( const pg = getKysely() const now = new Date() const viewerId = getUserId(authToken) - const [isVoteRemovedFromGroup, voteRemovedResult] = await Promise.all([ - r - .table('RetroReflectionGroup') - .get(reflectionGroupId) - .update((group: RValue) => { - return r.branch( - group('voterIds').offsetsOf(userId).count().ge(1), - { - updatedAt: now, - voterIds: group('voterIds').deleteAt(group('voterIds').offsetsOf(userId).nth(0)) - }, - {} - ) - })('replaced') - .eq(1) - .run(), - pg - .updateTable('RetroReflectionGroup') - .set({ - voterIds: sql`array_cat( + const voteRemovedResult = await pg + .updateTable('RetroReflectionGroup') + .set({ + voterIds: sql`array_cat( "voterIds"[1:array_position("voterIds",${userId})-1], "voterIds"[array_position("voterIds",${userId})+1:] )` - }) - .where('id', '=', reflectionGroupId) - .where(sql`${userId}`, '=', sql`ANY("voterIds")`) - .executeTakeFirst() - ]) - const isVoteRemovedFromGroupPG = voteRemovedResult.numUpdatedRows === BigInt(1) - if (isVoteRemovedFromGroup !== isVoteRemovedFromGroupPG) - sendToSentry(new Error('MISMATCH VOTE REMOVED LOGIC')) + }) + .where('id', '=', reflectionGroupId) + .where(sql`${userId}`, '=', sql`ANY("voterIds")`) + .executeTakeFirst() + const isVoteRemovedFromGroup = voteRemovedResult.numUpdatedRows === BigInt(1) if (!isVoteRemovedFromGroup) { return standardError(new Error('Already removed vote'), {userId: viewerId}) } diff --git a/packages/server/graphql/mutations/helpers/updateReflectionLocation/addReflectionToGroup.ts b/packages/server/graphql/mutations/helpers/updateReflectionLocation/addReflectionToGroup.ts index a6d151f0127..5d51532369c 100644 --- a/packages/server/graphql/mutations/helpers/updateReflectionLocation/addReflectionToGroup.ts +++ b/packages/server/graphql/mutations/helpers/updateReflectionLocation/addReflectionToGroup.ts @@ -2,7 +2,6 @@ import dndNoise from 'parabol-client/utils/dndNoise' import getGroupSmartTitle from 'parabol-client/utils/smartGroup/getGroupSmartTitle' import getRethink from '../../../../database/rethinkDriver' import Reflection from '../../../../database/types/Reflection' -import ReflectionGroup from '../../../../database/types/ReflectionGroup' import getKysely from '../../../../postgres/getKysely' import {GQLContext} from './../../../graphql' import updateSmartGroupTitle from './updateSmartGroupTitle' @@ -19,14 +18,13 @@ const addReflectionToGroup = async ( const reflection = await dataLoader.get('retroReflections').load(reflectionId) if (!reflection) throw new Error('Reflection not found') const {reflectionGroupId: oldReflectionGroupId, meetingId: reflectionMeetingId} = reflection - const {reflectionGroup, oldReflectionGroup} = await r({ - reflectionGroup: r - .table('RetroReflectionGroup') - .get(reflectionGroupId) as unknown as ReflectionGroup, - oldReflectionGroup: r - .table('RetroReflectionGroup') - .get(oldReflectionGroupId) as unknown as ReflectionGroup - }).run() + const [reflectionGroup, oldReflectionGroup] = await Promise.all([ + dataLoader.get('retroReflectionGroups').loadNonNull(reflectionGroupId), + dataLoader.get('retroReflectionGroups').loadNonNull(oldReflectionGroupId) + ]) + dataLoader.get('retroReflectionGroups').clear(reflectionGroupId) + dataLoader.get('retroReflectionGroups').clear(oldReflectionGroupId) + if (!reflectionGroup || !reflectionGroup.isActive) { throw new Error('Reflection group not found') } @@ -79,22 +77,11 @@ const addReflectionToGroup = async ( const newGroupHasSmartTitle = reflectionGroup.title === reflectionGroup.smartTitle if (oldGroupHasSingleReflectionCustomTitle && newGroupHasSmartTitle) { // Edge case of dragging a single card with a custom group name on a group with smart name - await Promise.all([ - pg - .updateTable('RetroReflectionGroup') - .set({title: oldReflectionGroup.title, smartTitle: nextTitle}) - .where('id', '=', reflectionGroupId) - .execute(), - r - .table('RetroReflectionGroup') - .get(reflectionGroupId) - .update({ - title: oldReflectionGroup.title, - smartTitle: nextTitle, - updatedAt: now - }) - .run() - ]) + await pg + .updateTable('RetroReflectionGroup') + .set({title: oldReflectionGroup.title, smartTitle: nextTitle}) + .where('id', '=', reflectionGroupId) + .execute() } else { await updateSmartGroupTitle(reflectionGroupId, nextTitle) } @@ -103,21 +90,11 @@ const addReflectionToGroup = async ( const oldTitle = getGroupSmartTitle(oldReflections) await updateSmartGroupTitle(oldReflectionGroupId, oldTitle) } else { - await Promise.all([ - pg - .updateTable('RetroReflectionGroup') - .set({isActive: false}) - .where('id', '=', oldReflectionGroupId) - .execute(), - r - .table('RetroReflectionGroup') - .get(oldReflectionGroupId) - .update({ - isActive: false, - updatedAt: now - }) - .run() - ]) + await pg + .updateTable('RetroReflectionGroup') + .set({isActive: false}) + .where('id', '=', oldReflectionGroupId) + .execute() } } return reflectionGroupId diff --git a/packages/server/graphql/mutations/helpers/updateReflectionLocation/removeReflectionFromGroup.ts b/packages/server/graphql/mutations/helpers/updateReflectionLocation/removeReflectionFromGroup.ts index 66cbb18c44f..c7166d817dc 100644 --- a/packages/server/graphql/mutations/helpers/updateReflectionLocation/removeReflectionFromGroup.ts +++ b/packages/server/graphql/mutations/helpers/updateReflectionLocation/removeReflectionFromGroup.ts @@ -14,17 +14,16 @@ const removeReflectionFromGroup = async (reflectionId: string, {dataLoader}: GQL const reflection = await dataLoader.get('retroReflections').load(reflectionId) if (!reflection) throw new Error('Reflection not found') const {reflectionGroupId: oldReflectionGroupId, meetingId, promptId} = reflection - const [oldReflectionGroup, reflectionGroupsInColumn, meeting] = await Promise.all([ - dataLoader.get('retroReflectionGroups').load(oldReflectionGroupId), - r - .table('RetroReflectionGroup') - .getAll(meetingId, {index: 'meetingId'}) - .filter({isActive: true, promptId}) - .orderBy('sortOrder') - .run(), + const [meetingReflectionGroups, meeting] = await Promise.all([ + dataLoader.get('retroReflectionGroupsByMeetingId').load(meetingId), dataLoader.get('newMeetings').load(meetingId) ]) - + dataLoader.get('retroReflectionGroupsByMeetingId').clear(meetingId) + dataLoader.get('retroReflectionGroups').clearAll() + const oldReflectionGroup = meetingReflectionGroups.find((g) => g.id === oldReflectionGroupId)! + const reflectionGroupsInColumn = meetingReflectionGroups + .filter((g) => g.promptId === promptId) + .sort((a, b) => (a.sortOrder < b.sortOrder ? -1 : 1)) let newSortOrder = 1e6 const oldReflectionGroupIdx = reflectionGroupsInColumn.findIndex( (group) => group.id === oldReflectionGroup.id @@ -47,7 +46,6 @@ const removeReflectionFromGroup = async (reflectionId: string, {dataLoader}: GQL const {id: reflectionGroupId} = reflectionGroup await Promise.all([ pg.insertInto('RetroReflectionGroup').values(reflectionGroup).execute(), - r.table('RetroReflectionGroup').insert(reflectionGroup).run(), r .table('RetroReflection') .get(reflectionId) @@ -77,21 +75,11 @@ const removeReflectionFromGroup = async (reflectionId: string, {dataLoader}: GQL const oldTitle = getGroupSmartTitle(oldReflections) await updateSmartGroupTitle(oldReflectionGroupId, oldTitle) } else { - await Promise.all([ - pg - .updateTable('RetroReflectionGroup') - .set({isActive: false}) - .where('id', '=', oldReflectionGroupId) - .execute(), - r - .table('RetroReflectionGroup') - .get(oldReflectionGroupId) - .update({ - isActive: false, - updatedAt: now - }) - .run() - ]) + await pg + .updateTable('RetroReflectionGroup') + .set({isActive: false}) + .where('id', '=', oldReflectionGroupId) + .execute() } return reflectionGroupId } diff --git a/packages/server/graphql/mutations/helpers/updateReflectionLocation/updateSmartGroupTitle.ts b/packages/server/graphql/mutations/helpers/updateReflectionLocation/updateSmartGroupTitle.ts index da34c3ab2fd..90aab3da6cc 100644 --- a/packages/server/graphql/mutations/helpers/updateReflectionLocation/updateSmartGroupTitle.ts +++ b/packages/server/graphql/mutations/helpers/updateReflectionLocation/updateSmartGroupTitle.ts @@ -1,31 +1,16 @@ import {sql} from 'kysely' -import getRethink from '../../../../database/rethinkDriver' -import {RValue} from '../../../../database/stricterR' import getKysely from '../../../../postgres/getKysely' const updateSmartGroupTitle = async (reflectionGroupId: string, smartTitle: string) => { - const r = await getRethink() const pg = getKysely() - const now = new Date() - await Promise.all([ - pg - .updateTable('RetroReflectionGroup') - .set({ - smartTitle, - title: sql`CASE WHEN "smartTitle" = "title" THEN ${smartTitle} ELSE "title" END` - }) - .where('id', '=', reflectionGroupId) - .execute(), - r - .table('RetroReflectionGroup') - .get(reflectionGroupId) - .update((g: RValue) => ({ - smartTitle, - title: r.branch(g('smartTitle').eq(g('title')), smartTitle, g('title')), - updatedAt: now - })) - .run() - ]) + await pg + .updateTable('RetroReflectionGroup') + .set({ + smartTitle, + title: sql`CASE WHEN "smartTitle" = "title" THEN ${smartTitle} ELSE "title" END` + }) + .where('id', '=', reflectionGroupId) + .execute() } export default updateSmartGroupTitle diff --git a/packages/server/graphql/mutations/resetRetroMeetingToGroupStage.ts b/packages/server/graphql/mutations/resetRetroMeetingToGroupStage.ts index 721182c7242..acb1a07e001 100644 --- a/packages/server/graphql/mutations/resetRetroMeetingToGroupStage.ts +++ b/packages/server/graphql/mutations/resetRetroMeetingToGroupStage.ts @@ -115,11 +115,6 @@ const resetRetroMeetingToGroupStage = { .set({voterIds: [], summary: null, discussionPromptQuestion: null}) .where('id', 'in', reflectionGroupIds) .execute(), - r - .table('RetroReflectionGroup') - .getAll(r.args(reflectionGroupIds)) - .update({voterIds: [], summary: null, discussionPromptQuestion: null}) - .run(), r.table('NewMeeting').get(meetingId).update({phases: newPhases}).run(), (r.table('MeetingMember').getAll(meetingId, {index: 'meetingId'}) as any) .update({votesRemaining: meeting.totalVotes}) diff --git a/packages/server/graphql/mutations/updateReflectionGroupTitle.ts b/packages/server/graphql/mutations/updateReflectionGroupTitle.ts index 24e612d8615..41a3ca4978c 100644 --- a/packages/server/graphql/mutations/updateReflectionGroupTitle.ts +++ b/packages/server/graphql/mutations/updateReflectionGroupTitle.ts @@ -2,7 +2,6 @@ import {GraphQLID, GraphQLNonNull, GraphQLString} from 'graphql' import {SubscriptionChannel} from 'parabol-client/types/constEnums' import isPhaseComplete from 'parabol-client/utils/meetings/isPhaseComplete' import stringSimilarity from 'string-similarity' -import getRethink from '../../database/rethinkDriver' import getKysely from '../../postgres/getKysely' import {analytics} from '../../utils/analytics/analytics' import {getUserId, isTeamMember} from '../../utils/authorization' @@ -32,14 +31,14 @@ export default { {reflectionGroupId, title}: UpdateReflectionGroupTitleMutationVariables, {authToken, dataLoader, socketId: mutatorId}: GQLContext ) { - const r = await getRethink() const pg = getKysely() const operationId = dataLoader.share() const subOptions = {operationId, mutatorId} // AUTH const viewerId = getUserId(authToken) - const reflectionGroup = await r.table('RetroReflectionGroup').get(reflectionGroupId).run() + const reflectionGroup = await dataLoader.get('retroReflectionGroups').load(reflectionGroupId) + if (!reflectionGroup) { return standardError(new Error('Reflection group not found'), {userId: viewerId}) } @@ -70,30 +69,19 @@ export default { return {error: {message: 'Title is too long'}} } - const allTitles = await r - .table('RetroReflectionGroup') - .getAll(meetingId, {index: 'meetingId'}) - .filter({isActive: true})('title') - .run() + const allGroups = await dataLoader.get('retroReflectionGroupsByMeetingId').load(meetingId) + const allTitles = allGroups.map((g) => g.title) if (allTitles.includes(normalizedTitle)) { return standardError(new Error('Group titles must be unique'), {userId: viewerId}) } // RESOLUTION - await Promise.all([ - r - .table('RetroReflectionGroup') - .get(reflectionGroupId) - .update({ - title: normalizedTitle - }) - .run(), - pg - .updateTable('RetroReflectionGroup') - .set({title: normalizedTitle}) - .where('id', '=', reflectionGroupId) - .execute() - ]) + dataLoader.get('retroReflectionGroups').clear(reflectionGroupId) + await pg + .updateTable('RetroReflectionGroup') + .set({title: normalizedTitle}) + .where('id', '=', reflectionGroupId) + .execute() if (smartTitle && smartTitle === oldTitle) { // let's see how smart those smart titles really are. A high similarity means very helpful. Not calling this mutation means perfect! diff --git a/packages/server/graphql/mutations/voteForReflectionGroup.ts b/packages/server/graphql/mutations/voteForReflectionGroup.ts index fa2d0882c1a..fe193c618c9 100644 --- a/packages/server/graphql/mutations/voteForReflectionGroup.ts +++ b/packages/server/graphql/mutations/voteForReflectionGroup.ts @@ -35,7 +35,7 @@ export default { // AUTH const viewerId = getUserId(authToken) - const reflectionGroup = await r.table('RetroReflectionGroup').get(reflectionGroupId).run() + const reflectionGroup = await dataLoader.get('retroReflectionGroups').load(reflectionGroupId) if (!reflectionGroup || !reflectionGroup.isActive) { return standardError(new Error('Reflection group not found'), { userId: viewerId, @@ -66,6 +66,7 @@ export default { } // RESOLUTION + dataLoader.get('retroReflectionGroups').clear(reflectionGroupId) if (isUnvote) { const votingError = await safelyWithdrawVote( authToken, diff --git a/packages/server/graphql/private/mutations/backupOrganization.ts b/packages/server/graphql/private/mutations/backupOrganization.ts index 5938ccbfbd4..f41038a198d 100644 --- a/packages/server/graphql/private/mutations/backupOrganization.ts +++ b/packages/server/graphql/private/mutations/backupOrganization.ts @@ -280,23 +280,6 @@ const backupOrganization: MutationResolvers['backupOrganization'] = async (_sour ) .coerceTo('array') .do((items: RValue) => r.db(DESTINATION).table('RetroReflection').insert(items)), - retroReflectionGroup: ( - r.table('RetroReflectionGroup').getAll(r.args(meetingIds), {index: 'meetingId'}) as any - ) - .coerceTo('array') - .do((items: RValue) => r.db(DESTINATION).table('RetroReflectionGroup').insert(items)), - // really hard things to clone - reflectionGroupComments: r - .table('RetroReflectionGroup') - .getAll(r.args(meetingIds), {index: 'meetingId'})('id') - .coerceTo('array') - .do((discussionIds: RValue) => { - return ( - r.table('Comment').getAll(r.args(discussionIds), {index: 'discussionId'}) as any - ) - .coerceTo('array') - .do((items: RValue) => r.db(DESTINATION).table('Comment').insert(items)) - }), agendaItemComments: r .table('AgendaItem') .getAll(r.args(meetingIds), {index: 'meetingId'})('id') diff --git a/packages/server/graphql/private/mutations/checkRethinkPgEquality.ts b/packages/server/graphql/private/mutations/checkRethinkPgEquality.ts index 818af10d9e6..df330fc7aa1 100644 --- a/packages/server/graphql/private/mutations/checkRethinkPgEquality.ts +++ b/packages/server/graphql/private/mutations/checkRethinkPgEquality.ts @@ -38,7 +38,7 @@ const checkRethinkPgEquality: MutationResolvers['checkRethinkPgEquality'] = asyn const rowCountResult = await checkRowCount(tableName) const rethinkQuery = (updatedAt: Date, id: string | number) => { return r - .table('RetroReflectionGroup') + .table('RetroReflectionGroup' as any) .between([updatedAt, id], [r.maxval, r.maxval], { index: 'updatedAtId', leftBound: 'open', diff --git a/packages/server/graphql/public/types/RetrospectiveMeeting.ts b/packages/server/graphql/public/types/RetrospectiveMeeting.ts index a4ad2df6f03..1501e3805e7 100644 --- a/packages/server/graphql/public/types/RetrospectiveMeeting.ts +++ b/packages/server/graphql/public/types/RetrospectiveMeeting.ts @@ -1,5 +1,4 @@ import toTeamMemberId from '../../../../client/utils/relay/toTeamMemberId' -import ReflectionGroupType from '../../../database/types/ReflectionGroup' import RetroMeetingMember from '../../../database/types/RetroMeetingMember' import {getUserId} from '../../../utils/authorization' import filterTasksByMeeting from '../../../utils/filterTasksByMeeting' @@ -20,7 +19,7 @@ const RetrospectiveMeeting: RetrospectiveMeetingResolvers = { reflectionCount: ({reflectionCount}) => reflectionCount || 0, reflectionGroup: async ({id: meetingId}, {reflectionGroupId}, {dataLoader}) => { const reflectionGroup = await dataLoader.get('retroReflectionGroups').load(reflectionGroupId) - if (reflectionGroup.meetingId !== meetingId) return null + if (reflectionGroup?.meetingId !== meetingId) return null return reflectionGroup }, reflectionGroups: async ({id: meetingId}, {sortBy}, {dataLoader}) => { @@ -28,9 +27,7 @@ const RetrospectiveMeeting: RetrospectiveMeetingResolvers = { .get('retroReflectionGroupsByMeetingId') .load(meetingId) if (sortBy === 'voteCount') { - reflectionGroups.sort((a: ReflectionGroupType, b: ReflectionGroupType) => - a.voterIds.length < b.voterIds.length ? 1 : -1 - ) + reflectionGroups.sort((a, b) => (a.voterIds.length < b.voterIds.length ? 1 : -1)) return reflectionGroups } else if (sortBy === 'stageOrder') { const meeting = await dataLoader.get('newMeetings').load(meetingId) @@ -40,18 +37,16 @@ const RetrospectiveMeeting: RetrospectiveMeetingResolvers = { const {stages} = discussPhase // for early terminations the stages may not exist const sortLookup = {} as {[reflectionGroupId: string]: number} - reflectionGroups.forEach((group: ReflectionGroupType) => { + reflectionGroups.forEach((group) => { const idx = stages.findIndex((stage) => stage.reflectionGroupId === group.id) sortLookup[group.id] = idx }) - reflectionGroups.sort((a: ReflectionGroupType, b: ReflectionGroupType) => { + reflectionGroups.sort((a, b) => { return sortLookup[a.id]! < sortLookup[b.id]! ? -1 : 1 }) return reflectionGroups } - reflectionGroups.sort((a: ReflectionGroupType, b: ReflectionGroupType) => - a.sortOrder < b.sortOrder ? -1 : 1 - ) + reflectionGroups.sort((a, b) => (a.sortOrder < b.sortOrder ? -1 : 1)) return reflectionGroups }, taskCount: ({taskCount}) => taskCount || 0, From 62e123c9ddbed3dc3f95bbc0d59c3024f0c2c260 Mon Sep 17 00:00:00 2001 From: Matt Krick Date: Thu, 30 May 2024 13:03:34 -0700 Subject: [PATCH 02/47] chore: add PG RetroReflection table Signed-off-by: Matt Krick --- .../graphql/mutations/createReflection.ts | 3 ++ .../1717096624786_retroReflection-phaes1.ts | 49 +++++++++++++++++++ 2 files changed, 52 insertions(+) create mode 100644 packages/server/postgres/migrations/1717096624786_retroReflection-phaes1.ts diff --git a/packages/server/graphql/mutations/createReflection.ts b/packages/server/graphql/mutations/createReflection.ts index 67e7420f3c6..0108ee8501a 100644 --- a/packages/server/graphql/mutations/createReflection.ts +++ b/packages/server/graphql/mutations/createReflection.ts @@ -63,6 +63,9 @@ export default { // VALIDATION const normalizedContent = normalizeRawDraftJS(content) + if (normalizedContent.length > 2000) { + return {error: {message: 'Reflection content is too long'}} + } // RESOLUTION const plaintextContent = extractTextFromDraftString(normalizedContent) diff --git a/packages/server/postgres/migrations/1717096624786_retroReflection-phaes1.ts b/packages/server/postgres/migrations/1717096624786_retroReflection-phaes1.ts new file mode 100644 index 00000000000..83ddab97cc8 --- /dev/null +++ b/packages/server/postgres/migrations/1717096624786_retroReflection-phaes1.ts @@ -0,0 +1,49 @@ +import {Client} from 'pg' +import getPgConfig from '../getPgConfig' + +export async function up() { + const client = new Client(getPgConfig()) + await client.connect() + await client.query(` + CREATE TABLE IF NOT EXISTS "RetroReflection" ( + "id" VARCHAR(100) PRIMARY KEY, + "createdAt" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(), + "updatedAt" TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP NOT NULL, + "isActive" BOOLEAN NOT NULL DEFAULT TRUE, + "meetingId" VARCHAR(100) NOT NULL, + "promptId" VARCHAR(100) NOT NULL, + "sortOrder" INT NOT NULL DEFAULT 0, + "creatorId" VARCHAR(100) NOT NULL, + "content" VARCHAR(2000) NOT NULL, + "plaintextContent" VARCHAR(2000) NOT NULL, + "entities" JSONB[] NOT NULL DEFAULT '{}', + "sentimentScore" INT, + "reactjis" JSONB[] NOT NULL DEFAULT '{}', + "reflectionGroupId" VARCHAR(100) NOT NULL, + CONSTRAINT "fk_creatorId" + FOREIGN KEY("creatorId") + REFERENCES "User"("id") + ON DELETE CASCADE, + CONSTRAINT "fk_reflectionGroupId" + FOREIGN KEY("reflectionGroupId") + REFERENCES "RetroReflectionGroup"("id") + ON DELETE CASCADE + ); + CREATE INDEX IF NOT EXISTS "idx_RetroReflection_meetingId" ON "RetroReflection"("meetingId"); + CREATE INDEX IF NOT EXISTS "idx_RetroReflection_promptId" ON "RetroReflection"("promptId"); + CREATE INDEX IF NOT EXISTS "idx_RetroReflection_creatorId" ON "RetroReflection"("creatorId"); + CREATE INDEX IF NOT EXISTS "idx_RetroReflection_reflectionGroupId" ON "RetroReflection"("reflectionGroupId"); + DROP TRIGGER IF EXISTS "update_RetroReflection_updatedAt" ON "RetroReflection"; + CREATE TRIGGER "update_RetroReflection_updatedAt" BEFORE UPDATE ON "RetroReflection" FOR EACH ROW EXECUTE PROCEDURE "set_updatedAt"(); +`) + await client.end() +} + +export async function down() { + const client = new Client(getPgConfig()) + await client.connect() + await client.query(` + DROP TABLE IF EXISTS "RetroReflection"; + `) + await client.end() +} From c38f86c056e773aeeffb972d2d3fd8b84e5d95f0 Mon Sep 17 00:00:00 2001 From: Matt Krick Date: Mon, 3 Jun 2024 15:33:03 -0700 Subject: [PATCH 03/47] chore: write to PG RetroReflection table Signed-off-by: Matt Krick --- package.json | 2 +- .../database/types/GoogleAnalyzedEntity.ts | 1 + packages/server/database/types/Reactji.ts | 1 + packages/server/database/types/Reflection.ts | 1 - .../rethinkForeignKeyLoaderMakers.ts | 2 + .../rethinkPrimaryKeyLoaderMakers.ts | 1 + .../graphql/mutations/createReflection.ts | 1 + .../mutations/helpers/handleCompletedStage.ts | 15 ++--- .../helpers/notifications/SlackNotifier.ts | 2 +- .../helpers/removeEmptyReflectionGroup.ts | 18 +++--- .../helpers/removeEmptyReflections.ts | 16 +++-- .../addReflectionToGroup.ts | 57 +++++++++-------- .../removeReflectionFromGroup.ts | 16 +++-- .../graphql/mutations/removeReflection.ts | 30 ++++++--- .../mutations/updateReflectionContent.ts | 52 ++++++++++------ .../private/mutations/backupOrganization.ts | 5 -- .../private/mutations/hardDeleteUser.ts | 8 +++ .../public/mutations/addReactjiToReactable.ts | 61 ++++++++++--------- .../1717096624786_retroReflection-phaes1.ts | 4 +- yarn.lock | 25 ++++++-- 20 files changed, 184 insertions(+), 134 deletions(-) diff --git a/package.json b/package.json index e85bf4d6f16..8edb5ae0d5a 100644 --- a/package.json +++ b/package.json @@ -105,7 +105,7 @@ "husky": "^7.0.4", "jscodeshift": "^0.14.0", "kysely": "^0.27.3", - "kysely-codegen": "^0.11.0", + "kysely-codegen": "^0.15.0", "lerna": "^6.4.1", "mini-css-extract-plugin": "^2.7.2", "minimist": "^1.2.5", diff --git a/packages/server/database/types/GoogleAnalyzedEntity.ts b/packages/server/database/types/GoogleAnalyzedEntity.ts index b148d69967e..cea24fa8514 100644 --- a/packages/server/database/types/GoogleAnalyzedEntity.ts +++ b/packages/server/database/types/GoogleAnalyzedEntity.ts @@ -5,6 +5,7 @@ interface Input { } export default class GoogleAnalyzedEntity { + [key: string]: any lemma?: string name: string salience: number diff --git a/packages/server/database/types/Reactji.ts b/packages/server/database/types/Reactji.ts index fa36d325351..d23dec48681 100644 --- a/packages/server/database/types/Reactji.ts +++ b/packages/server/database/types/Reactji.ts @@ -4,6 +4,7 @@ interface Input { } export default class Reactji { + [key: string]: any userId: string id: string constructor(input: Input) { diff --git a/packages/server/database/types/Reflection.ts b/packages/server/database/types/Reflection.ts index c238a7a8dc2..d682e59d4d2 100644 --- a/packages/server/database/types/Reflection.ts +++ b/packages/server/database/types/Reflection.ts @@ -21,7 +21,6 @@ export interface ReflectionInput { export default class Reflection { id: string - autoReflectionGroupId?: string createdAt: Date // userId of the creator creatorId: string diff --git a/packages/server/dataloader/rethinkForeignKeyLoaderMakers.ts b/packages/server/dataloader/rethinkForeignKeyLoaderMakers.ts index 34ad30aca6f..d301d186beb 100644 --- a/packages/server/dataloader/rethinkForeignKeyLoaderMakers.ts +++ b/packages/server/dataloader/rethinkForeignKeyLoaderMakers.ts @@ -166,6 +166,7 @@ export const scalesByTeamId = new RethinkForeignKeyLoaderMaker( } ) +// Migrating to PG by June 30, 2024 export const retroReflectionsByMeetingId = new RethinkForeignKeyLoaderMaker( 'retroReflections', 'meetingId', @@ -179,6 +180,7 @@ export const retroReflectionsByMeetingId = new RethinkForeignKeyLoaderMaker( } ) +// Migrating to PG by June 30, 2024 export const retroReflectionsByGroupId = new RethinkForeignKeyLoaderMaker( 'retroReflections', 'reflectionGroupId', diff --git a/packages/server/dataloader/rethinkPrimaryKeyLoaderMakers.ts b/packages/server/dataloader/rethinkPrimaryKeyLoaderMakers.ts index 1e4f2d32e95..75497fb3bc0 100644 --- a/packages/server/dataloader/rethinkPrimaryKeyLoaderMakers.ts +++ b/packages/server/dataloader/rethinkPrimaryKeyLoaderMakers.ts @@ -16,6 +16,7 @@ export const notifications = new RethinkPrimaryKeyLoaderMaker('Notification') export const organizations = new RethinkPrimaryKeyLoaderMaker('Organization') export const organizationUsers = new RethinkPrimaryKeyLoaderMaker('OrganizationUser') export const templateScales = new RethinkPrimaryKeyLoaderMaker('TemplateScale') +// Migrating to PG by June 30, 2024 export const retroReflections = new RethinkPrimaryKeyLoaderMaker('RetroReflection') export const slackAuths = new RethinkPrimaryKeyLoaderMaker('SlackAuth') export const slackNotifications = new RethinkPrimaryKeyLoaderMaker('SlackNotification') diff --git a/packages/server/graphql/mutations/createReflection.ts b/packages/server/graphql/mutations/createReflection.ts index 0108ee8501a..7314f29e21e 100644 --- a/packages/server/graphql/mutations/createReflection.ts +++ b/packages/server/graphql/mutations/createReflection.ts @@ -102,6 +102,7 @@ export default { await Promise.all([ pg.insertInto('RetroReflectionGroup').values(reflectionGroup).execute(), + pg.insertInto('RetroReflection').values(reflection).execute(), r.table('RetroReflection').insert(reflection).run() ]) diff --git a/packages/server/graphql/mutations/helpers/handleCompletedStage.ts b/packages/server/graphql/mutations/helpers/handleCompletedStage.ts index 94f44d7f401..fe160315b2e 100644 --- a/packages/server/graphql/mutations/helpers/handleCompletedStage.ts +++ b/packages/server/graphql/mutations/helpers/handleCompletedStage.ts @@ -2,7 +2,6 @@ import {AUTO_GROUPING_THRESHOLD, GROUP, REFLECT, VOTE} from 'parabol-client/util import unlockAllStagesForPhase from 'parabol-client/utils/unlockAllStagesForPhase' import {r} from 'rethinkdb-ts' import groupReflections from '../../../../client/utils/smartGroup/groupReflections' -import getRethink from '../../../database/rethinkDriver' import DiscussStage from '../../../database/types/DiscussStage' import GenericMeetingStage from '../../../database/types/GenericMeetingStage' import MeetingRetrospective from '../../../database/types/MeetingRetrospective' @@ -30,22 +29,16 @@ const handleCompletedRetrospectiveStage = async ( dataLoader: DataLoaderWorker ) => { if (stage.phaseType === REFLECT || stage.phaseType === GROUP) { - const data: Record = await removeEmptyReflections(meeting) + const data: Record = await removeEmptyReflections(meeting, dataLoader) if (stage.phaseType === REFLECT) { - const r = await getRethink() const pg = getKysely() - const [reflectionGroups, reflections] = await Promise.all([ + const [reflectionGroups, unsortedReflections] = await Promise.all([ dataLoader.get('retroReflectionGroupsByMeetingId').load(meeting.id), - r - .table('RetroReflection') - .getAll(meeting.id, {index: 'meetingId'}) - .filter({isActive: true}) - .orderBy('createdAt') - .run() + dataLoader.get('retroReflectionsByMeetingId').load(meeting.id) ]) - + const reflections = unsortedReflections.sort((a, b) => (a.createdAt < b.createdAt ? -1 : 1)) const {reflectionGroupMapping} = groupReflections(reflections.slice(), { groupingThreshold: AUTO_GROUPING_THRESHOLD }) diff --git a/packages/server/graphql/mutations/helpers/notifications/SlackNotifier.ts b/packages/server/graphql/mutations/helpers/notifications/SlackNotifier.ts index 7ebc7bdcf61..8ec3bd2bda7 100644 --- a/packages/server/graphql/mutations/helpers/notifications/SlackNotifier.ts +++ b/packages/server/graphql/mutations/helpers/notifications/SlackNotifier.ts @@ -598,7 +598,7 @@ export const SlackNotifier = { dataLoader.get('users').loadNonNull(userId), dataLoader.get('newMeetings').load(meetingId), dataLoader.get('retroReflectionGroups').loadNonNull(reflectionGroupId), - r.table('RetroReflection').getAll(reflectionGroupId, {index: 'reflectionGroupId'}).run(), + dataLoader.get('retroReflectionsByGroupId').load(reflectionGroupId), r .table('SlackAuth') .getAll(userId, {index: 'userId'}) diff --git a/packages/server/graphql/mutations/helpers/removeEmptyReflectionGroup.ts b/packages/server/graphql/mutations/helpers/removeEmptyReflectionGroup.ts index 7958457a558..a2b6e63b1ba 100644 --- a/packages/server/graphql/mutations/helpers/removeEmptyReflectionGroup.ts +++ b/packages/server/graphql/mutations/helpers/removeEmptyReflectionGroup.ts @@ -1,20 +1,18 @@ -import getRethink from '../../../database/rethinkDriver' +import {DataLoaderInstance} from '../../../dataloader/RootDataLoader' import getKysely from '../../../postgres/getKysely' const removeEmptyReflectionGroup = async ( reflectionGroupId: string, - oldReflectionGroupId: string + oldReflectionGroupId: string, + dataLoader: DataLoaderInstance ) => { - const r = await getRethink() const pg = getKysely() if (!reflectionGroupId) return false - const reflectionCount = await r - .table('RetroReflection') - .getAll(oldReflectionGroupId, {index: 'reflectionGroupId'}) - .filter({isActive: true}) - .count() - .run() - if (reflectionCount > 0) return + const reflectionsInGroup = await dataLoader + .get('retroReflectionsByGroupId') + .load(oldReflectionGroupId) + + if (reflectionsInGroup.length > 0) return return pg .updateTable('RetroReflectionGroup') diff --git a/packages/server/graphql/mutations/helpers/removeEmptyReflections.ts b/packages/server/graphql/mutations/helpers/removeEmptyReflections.ts index 1745e5d97f8..0532e1c4054 100644 --- a/packages/server/graphql/mutations/helpers/removeEmptyReflections.ts +++ b/packages/server/graphql/mutations/helpers/removeEmptyReflections.ts @@ -1,17 +1,16 @@ import extractTextFromDraftString from 'parabol-client/utils/draftjs/extractTextFromDraftString' import getRethink from '../../../database/rethinkDriver' import Meeting from '../../../database/types/Meeting' +import {DataLoaderInstance} from '../../../dataloader/RootDataLoader' import getKysely from '../../../postgres/getKysely' -const removeEmptyReflections = async (meeting: Meeting) => { +const removeEmptyReflections = async (meeting: Meeting, dataLoader: DataLoaderInstance) => { const r = await getRethink() const pg = getKysely() const {id: meetingId} = meeting - const reflections = await r - .table('RetroReflection') - .getAll(meetingId, {index: 'meetingId'}) - .filter({isActive: true}) - .run() + const reflections = await dataLoader.get('retroReflectionsByMeetingId').load(meetingId) + dataLoader.get('retroReflectionsByMeetingId').clear(meetingId) + dataLoader.get('retroReflections').clearAll() const emptyReflectionGroupIds = [] as string[] const emptyReflectionIds = [] as string[] reflections.forEach((reflection) => { @@ -30,6 +29,11 @@ const removeEmptyReflections = async (meeting: Meeting) => { isActive: false }) .run(), + pg + .updateTable('RetroReflection') + .set({isActive: false}) + .where('id', 'in', emptyReflectionIds) + .execute(), pg .updateTable('RetroReflectionGroup') .set({isActive: false}) diff --git a/packages/server/graphql/mutations/helpers/updateReflectionLocation/addReflectionToGroup.ts b/packages/server/graphql/mutations/helpers/updateReflectionLocation/addReflectionToGroup.ts index 5d51532369c..e858cd01f4a 100644 --- a/packages/server/graphql/mutations/helpers/updateReflectionLocation/addReflectionToGroup.ts +++ b/packages/server/graphql/mutations/helpers/updateReflectionLocation/addReflectionToGroup.ts @@ -1,7 +1,6 @@ import dndNoise from 'parabol-client/utils/dndNoise' import getGroupSmartTitle from 'parabol-client/utils/smartGroup/getGroupSmartTitle' import getRethink from '../../../../database/rethinkDriver' -import Reflection from '../../../../database/types/Reflection' import getKysely from '../../../../postgres/getKysely' import {GQLContext} from './../../../graphql' import updateSmartGroupTitle from './updateSmartGroupTitle' @@ -32,25 +31,33 @@ const addReflectionToGroup = async ( if (reflectionMeetingId !== meetingId) { throw new Error('Reflection group not found') } - const maxSortOrder = await r - .table('RetroReflection') - .getAll(reflectionGroupId, {index: 'reflectionGroupId'})('sortOrder') - .max() - .default(0) - .run() + const reflectionsInNextGroup = await dataLoader + .get('retroReflectionsByGroupId') + .load(reflectionGroupId) + dataLoader.get('retroReflectionsByGroupId').clear(reflectionGroupId) + const maxSortOrder = Math.max(0, ...reflectionsInNextGroup.map((r) => r.sortOrder)) // RESOLUTION const sortOrder = maxSortOrder + 1 + dndNoise() - await r - .table('RetroReflection') - .get(reflectionId) - .update({ - sortOrder, - reflectionGroupId, - updatedAt: now - }) - .run() - + await Promise.all([ + pg + .updateTable('RetroReflection') + .set({ + sortOrder, + reflectionGroupId + }) + .where('id', '=', reflectionId) + .execute(), + r + .table('RetroReflection') + .get(reflectionId) + .update({ + sortOrder, + reflectionGroupId, + updatedAt: now + }) + .run() + ]) // mutate the dataLoader cache reflection.sortOrder = sortOrder reflection.reflectionGroupId = reflectionGroupId @@ -58,18 +65,10 @@ const addReflectionToGroup = async ( if (oldReflectionGroupId !== reflectionGroupId) { // ths is not just a reorder within the same group - const {nextReflections, oldReflections} = await r({ - nextReflections: r - .table('RetroReflection') - .getAll(reflectionGroupId, {index: 'reflectionGroupId'}) - .filter({isActive: true}) - .coerceTo('array') as unknown as Reflection[], - oldReflections: r - .table('RetroReflection') - .getAll(oldReflectionGroupId, {index: 'reflectionGroupId'}) - .filter({isActive: true}) - .coerceTo('array') as unknown as Reflection[] - }).run() + const nextReflections = [...reflectionsInNextGroup, reflection] + const oldReflections = await dataLoader + .get('retroReflectionsByGroupId') + .load(oldReflectionGroupId) const nextTitle = smartTitle ?? getGroupSmartTitle(nextReflections) const oldGroupHasSingleReflectionCustomTitle = diff --git a/packages/server/graphql/mutations/helpers/updateReflectionLocation/removeReflectionFromGroup.ts b/packages/server/graphql/mutations/helpers/updateReflectionLocation/removeReflectionFromGroup.ts index c7166d817dc..59e14fa36af 100644 --- a/packages/server/graphql/mutations/helpers/updateReflectionLocation/removeReflectionFromGroup.ts +++ b/packages/server/graphql/mutations/helpers/updateReflectionLocation/removeReflectionFromGroup.ts @@ -46,6 +46,14 @@ const removeReflectionFromGroup = async (reflectionId: string, {dataLoader}: GQL const {id: reflectionGroupId} = reflectionGroup await Promise.all([ pg.insertInto('RetroReflectionGroup').values(reflectionGroup).execute(), + pg + .updateTable('RetroReflection') + .set({ + sortOrder: 0, + reflectionGroupId + }) + .where('id', '=', reflectionId) + .execute(), r .table('RetroReflection') .get(reflectionId) @@ -62,11 +70,9 @@ const removeReflectionFromGroup = async (reflectionId: string, {dataLoader}: GQL reflection.reflectionGroupId = reflectionGroupId const retroMeeting = meeting as MeetingRetrospective retroMeeting.nextAutoGroupThreshold = null - const oldReflections = await r - .table('RetroReflection') - .getAll(oldReflectionGroupId, {index: 'reflectionGroupId'}) - .filter({isActive: true}) - .run() + const oldReflections = await dataLoader + .get('retroReflectionsByGroupId') + .load(oldReflectionGroupId) const nextTitle = getGroupSmartTitle([reflection]) await updateSmartGroupTitle(reflectionGroupId, nextTitle) diff --git a/packages/server/graphql/mutations/removeReflection.ts b/packages/server/graphql/mutations/removeReflection.ts index aa00cace62a..c6fd1be8ab2 100644 --- a/packages/server/graphql/mutations/removeReflection.ts +++ b/packages/server/graphql/mutations/removeReflection.ts @@ -3,6 +3,7 @@ import {SubscriptionChannel} from 'parabol-client/types/constEnums' import isPhaseComplete from 'parabol-client/utils/meetings/isPhaseComplete' import unlockAllStagesForPhase from 'parabol-client/utils/unlockAllStagesForPhase' import getRethink from '../../database/rethinkDriver' +import getKysely from '../../postgres/getKysely' import {getUserId, isTeamMember} from '../../utils/authorization' import publish from '../../utils/publish' import standardError from '../../utils/standardError' @@ -24,13 +25,15 @@ export default { {authToken, dataLoader, socketId: mutatorId}: GQLContext ) { const r = await getRethink() + const pg = getKysely() const operationId = dataLoader.share() const now = new Date() const subOptions = {operationId, mutatorId} // AUTH const viewerId = getUserId(authToken) - const reflection = await r.table('RetroReflection').get(reflectionId).run() + const reflection = await dataLoader.get('retroReflections').load(reflectionId) + dataLoader.get('retroReflections').clear(reflectionId) if (!reflection) { return standardError(new Error('Reflection not found'), {userId: viewerId}) } @@ -49,15 +52,22 @@ export default { } // RESOLUTION - await r - .table('RetroReflection') - .get(reflectionId) - .update({ - isActive: false, - updatedAt: now - }) - .run() - await removeEmptyReflectionGroup(reflectionGroupId, reflectionGroupId) + await Promise.all([ + pg + .updateTable('RetroReflection') + .set({isActive: false}) + .where('id', '=', reflectionId) + .execute(), + r + .table('RetroReflection') + .get(reflectionId) + .update({ + isActive: false, + updatedAt: now + }) + .run() + ]) + await removeEmptyReflectionGroup(reflectionGroupId, reflectionGroupId, dataLoader) const reflections = await dataLoader.get('retroReflectionsByMeetingId').load(meetingId) let unlockedStageIds if (reflections.length === 0) { diff --git a/packages/server/graphql/mutations/updateReflectionContent.ts b/packages/server/graphql/mutations/updateReflectionContent.ts index 349bf654dba..1505b890f30 100644 --- a/packages/server/graphql/mutations/updateReflectionContent.ts +++ b/packages/server/graphql/mutations/updateReflectionContent.ts @@ -6,7 +6,7 @@ import getGroupSmartTitle from 'parabol-client/utils/smartGroup/getGroupSmartTit import normalizeRawDraftJS from 'parabol-client/validation/normalizeRawDraftJS' import stringSimilarity from 'string-similarity' import getRethink from '../../database/rethinkDriver' -import Reflection from '../../database/types/Reflection' +import getKysely from '../../postgres/getKysely' import {getUserId, isTeamMember} from '../../utils/authorization' import publish from '../../utils/publish' import standardError from '../../utils/standardError' @@ -35,13 +35,15 @@ export default { {authToken, dataLoader, socketId: mutatorId}: GQLContext ) { const r = await getRethink() + const pg = getKysely() const operationId = dataLoader.share() const now = new Date() const subOptions = {operationId, mutatorId} // AUTH const viewerId = getUserId(authToken) - const reflection = await r.table('RetroReflection').get(reflectionId).run() + const reflection = await dataLoader.get('retroReflections').load(reflectionId) + dataLoader.get('retroReflections').clear(reflectionId) if (!reflection) { return standardError(new Error('Reflection not found'), {userId: viewerId}) } @@ -67,6 +69,9 @@ export default { // VALIDATION const normalizedContent = normalizeRawDraftJS(content) + if (normalizedContent.length > 2000) { + return {error: {message: 'Reflection content is too long'}} + } // RESOLUTION const plaintextContent = extractTextFromDraftString(normalizedContent) @@ -81,23 +86,32 @@ export default { ? await getReflectionSentimentScore(question, plaintextContent) : reflection.sentimentScore : undefined - await r - .table('RetroReflection') - .get(reflectionId) - .update({ - content: normalizedContent, - entities, - sentimentScore, - plaintextContent, - updatedAt: now - }) - .run() - - const reflectionsInGroup = (await r - .table('RetroReflection') - .getAll(reflectionGroupId, {index: 'reflectionGroupId'}) - .filter({isActive: true}) - .run()) as Reflection[] + await Promise.all([ + pg + .updateTable('RetroReflection') + .set({ + content: normalizedContent, + entities, + sentimentScore, + plaintextContent + }) + .where('id', '=', reflectionId) + .execute(), + r + .table('RetroReflection') + .get(reflectionId) + .update({ + content: normalizedContent, + entities, + sentimentScore, + plaintextContent, + updatedAt: now + }) + .run() + ]) + const reflectionsInGroup = await dataLoader + .get('retroReflectionsByGroupId') + .load(reflectionGroupId) const newTitle = getGroupSmartTitle(reflectionsInGroup) await updateSmartGroupTitle(reflectionGroupId, newTitle) diff --git a/packages/server/graphql/private/mutations/backupOrganization.ts b/packages/server/graphql/private/mutations/backupOrganization.ts index f41038a198d..b4db1ae9ef5 100644 --- a/packages/server/graphql/private/mutations/backupOrganization.ts +++ b/packages/server/graphql/private/mutations/backupOrganization.ts @@ -275,11 +275,6 @@ const backupOrganization: MutationResolvers['backupOrganization'] = async (_sour .coerceTo('array') .do((meetingIds: RValue) => { return r({ - retroReflection: ( - r.table('RetroReflection').getAll(r.args(meetingIds), {index: 'meetingId'}) as any - ) - .coerceTo('array') - .do((items: RValue) => r.db(DESTINATION).table('RetroReflection').insert(items)), agendaItemComments: r .table('AgendaItem') .getAll(r.args(meetingIds), {index: 'meetingId'})('id') diff --git a/packages/server/graphql/private/mutations/hardDeleteUser.ts b/packages/server/graphql/private/mutations/hardDeleteUser.ts index 76bbe784f44..fe8f26951b6 100644 --- a/packages/server/graphql/private/mutations/hardDeleteUser.ts +++ b/packages/server/graphql/private/mutations/hardDeleteUser.ts @@ -1,6 +1,7 @@ import TeamMemberId from 'parabol-client/shared/gqlIds/TeamMemberId' import getRethink from '../../../database/rethinkDriver' import {RValue} from '../../../database/stricterR' +import getKysely from '../../../postgres/getKysely' import getPg from '../../../postgres/getPg' import {getUserByEmail} from '../../../postgres/queries/getUsersByEmails' import {getUserById} from '../../../postgres/queries/getUsersByIds' @@ -68,6 +69,7 @@ const hardDeleteUser: MutationResolvers['hardDeleteUser'] = async ( .map((row: RValue) => row('group')) .coerceTo('array') .run(), + // Migrating to PG by June 30, 2024 ( r .table('NewMeeting') @@ -147,6 +149,7 @@ const hardDeleteUser: MutationResolvers['hardDeleteUser'] = async ( .filter((row: RValue) => r(teamMemberIds).contains(row('teamMemberId'))) .delete(), pushInvitation: r.table('PushInvitation').getAll(userIdToDelete, {index: 'userId'}).delete(), + // Migrating to PG by June 30, 2024 retroReflection: r .table('RetroReflection') .getAll(r.args(retroReflectionIds), {index: 'id'}) @@ -197,6 +200,11 @@ const hardDeleteUser: MutationResolvers['hardDeleteUser'] = async ( // now postgres, after FKs are added then triggers should take care of children await Promise.all([ + getKysely() + .updateTable('RetroReflection') + .set({creatorId: tombstoneId}) + .where('creatorId', '=', userIdToDelete) + .execute(), pg.query(`DELETE FROM "AtlassianAuth" WHERE "userId" = $1`, [userIdToDelete]), pg.query(`DELETE FROM "GitHubAuth" WHERE "userId" = $1`, [userIdToDelete]), pg.query( diff --git a/packages/server/graphql/public/mutations/addReactjiToReactable.ts b/packages/server/graphql/public/mutations/addReactjiToReactable.ts index 2729d9b0d83..a35d00b5e7d 100644 --- a/packages/server/graphql/public/mutations/addReactjiToReactable.ts +++ b/packages/server/graphql/public/mutations/addReactjiToReactable.ts @@ -1,12 +1,11 @@ import {sql} from 'kysely' import TeamPromptResponseId from 'parabol-client/shared/gqlIds/TeamPromptResponseId' import {SubscriptionChannel, Threshold} from 'parabol-client/types/constEnums' -import {ValueOf} from 'parabol-client/types/generics' import toTeamMemberId from 'parabol-client/utils/relay/toTeamMemberId' +import {ValueOf} from '../../../../client/types/generics' import getRethink from '../../../database/rethinkDriver' import {RDatum} from '../../../database/stricterR' import Comment from '../../../database/types/Comment' -import {Reactable} from '../../../database/types/Reactable' import Reflection from '../../../database/types/Reflection' import getKysely from '../../../postgres/getKysely' import {analytics} from '../../../utils/analytics/analytics' @@ -19,13 +18,16 @@ import {ReactableEnumType} from '../../types/ReactableEnum' import getReactableType from '../../types/getReactableType' import {MutationResolvers} from '../resolverTypes' -const rethinkTableLookup = { - COMMENT: 'Comment', - REFLECTION: 'RetroReflection' +const dataLoaderLookup = { + RESPONSE: 'teamPromptResponses', + COMMENT: 'comments', + REFLECTION: 'retroReflections' } as const -const pgDataloaderLookup = { - RESPONSE: 'teamPromptResponses' +const tableLookup = { + RESPONSE: 'TeamPromptResponse', + COMMENT: 'Comment', + REFLECTION: 'RetroReflection' } as const const addReactjiToReactable: MutationResolvers['addReactjiToReactable'] = async ( @@ -53,16 +55,10 @@ const addReactjiToReactable: MutationResolvers['addReactjiToReactable'] = async const subOptions = {mutatorId, operationId} //AUTH - let reactable: Reactable - const pgLoaderName = pgDataloaderLookup[ - reactableType as keyof typeof pgDataloaderLookup - ] as ValueOf | null - const rethinkDbTable = rethinkTableLookup[reactableType as keyof typeof rethinkTableLookup] - if (pgLoaderName) { - reactable = await dataLoader.get(pgLoaderName).loadNonNull(reactableId) - } else { - reactable = (await r.table(rethinkDbTable).get(reactableId).run()) as Reactable - } + const loaderName = dataLoaderLookup[reactableType] + const reactable = await dataLoader.get(loaderName).load(reactableId) + dataLoader.get(loaderName).clear(reactableId) + if (!reactable) { return {error: {message: `Item does not exist`}} } @@ -96,30 +92,35 @@ const addReactjiToReactable: MutationResolvers['addReactjiToReactable'] = async // RESOLUTION const subDoc = {id: reactji, userId: viewerId} - if (pgLoaderName) { - const numberReactableId = TeamPromptResponseId.split(reactableId) + const tableName = tableLookup[reactableType] + const dbId = + tableName === 'TeamPromptResponse' ? TeamPromptResponseId.split(reactableId) : reactableId + + const updatePG = async (pgTable: ValueOf) => { + if (pgTable === 'Comment') return if (isRemove) { await pg - .updateTable('TeamPromptResponse') + .updateTable(pgTable) .set({reactjis: sql`array_remove("reactjis", (${reactji},${viewerId})::"Reactji")`}) - .where('id', '=', numberReactableId) + .where('id', '=', dbId) .execute() } else { await pg - .updateTable('TeamPromptResponse') + .updateTable(pgTable) .set({ reactjis: sql`arr_append_uniq("reactjis", (${reactji},${viewerId})::"Reactji")` }) - .where('id', '=', numberReactableId) + .where('id', '=', dbId) .execute() } + } - dataLoader.get(pgLoaderName).clear(reactableId) - } else { + const updateRethink = async (rethinkDbTable: ValueOf) => { + if (rethinkDbTable === 'TeamPromptResponse') return if (isRemove) { await r .table(rethinkDbTable) - .get(reactableId) + .get(dbId) .update((row: RDatum) => ({ reactjis: row('reactjis').difference([subDoc]), updatedAt: now @@ -128,7 +129,7 @@ const addReactjiToReactable: MutationResolvers['addReactjiToReactable'] = async } else { await r .table(rethinkDbTable) - .get(reactableId) + .get(dbId) .update((row: RDatum) => ({ reactjis: r.branch( row('reactjis').contains(subDoc), @@ -141,8 +142,12 @@ const addReactjiToReactable: MutationResolvers['addReactjiToReactable'] = async .run() } } + const [meeting] = await Promise.all([ + dataLoader.get('newMeetings').load(meetingId), + updatePG(tableName), + updateRethink(tableName) + ]) - const meeting = await dataLoader.get('newMeetings').load(meetingId) const {meetingType} = meeting const data = {reactableId, reactableType} diff --git a/packages/server/postgres/migrations/1717096624786_retroReflection-phaes1.ts b/packages/server/postgres/migrations/1717096624786_retroReflection-phaes1.ts index 83ddab97cc8..327000ed48c 100644 --- a/packages/server/postgres/migrations/1717096624786_retroReflection-phaes1.ts +++ b/packages/server/postgres/migrations/1717096624786_retroReflection-phaes1.ts @@ -16,9 +16,9 @@ export async function up() { "creatorId" VARCHAR(100) NOT NULL, "content" VARCHAR(2000) NOT NULL, "plaintextContent" VARCHAR(2000) NOT NULL, - "entities" JSONB[] NOT NULL DEFAULT '{}', + "entities" JSONB NOT NULL, "sentimentScore" INT, - "reactjis" JSONB[] NOT NULL DEFAULT '{}', + "reactjis" JSONB NOT NULL, "reflectionGroupId" VARCHAR(100) NOT NULL, CONSTRAINT "fk_creatorId" FOREIGN KEY("creatorId") diff --git a/yarn.lock b/yarn.lock index 9667d8329c9..e038121f0d5 100644 --- a/yarn.lock +++ b/yarn.lock @@ -11493,6 +11493,13 @@ dotenv-expand@5.1.0: resolved "https://registry.yarnpkg.com/dotenv-expand/-/dotenv-expand-5.1.0.tgz#3fbaf020bfd794884072ea26b1e9791d45a629f0" integrity sha512-YXQl1DSa4/PQyRfgrv6aoNjhasp/p4qs9FjJ4q4cQk+8m4r6k4ZSiEyytKG8f8W9gi8WsQtIObNmKd+tMzNTmA== +dotenv-expand@^11.0.6: + version "11.0.6" + resolved "https://registry.yarnpkg.com/dotenv-expand/-/dotenv-expand-11.0.6.tgz#f2c840fd924d7c77a94eff98f153331d876882d3" + integrity sha512-8NHi73otpWsZGBSZwwknTXS5pqMOrk9+Ssrna8xCaxkzEpU9OTf9R5ArQGVw03//Zmk9MOwLPng9WwndvpAJ5g== + dependencies: + dotenv "^16.4.4" + dotenv@8.0.0: version "8.0.0" resolved "https://registry.yarnpkg.com/dotenv/-/dotenv-8.0.0.tgz#ed310c165b4e8a97bb745b0a9d99c31bda566440" @@ -11503,11 +11510,16 @@ dotenv@8.6.0: resolved "https://registry.yarnpkg.com/dotenv/-/dotenv-8.6.0.tgz#061af664d19f7f4d8fc6e4ff9b584ce237adcb8b" integrity sha512-IrPdXQsk2BbzvCBGBOTmmSH5SodmqZNt4ERAZDmW4CT+tL8VtvinqywuANaFu4bOMWki16nqf0e4oC0QIaDr/g== -dotenv@^16.0.0, dotenv@^16.0.3: +dotenv@^16.0.0: version "16.0.3" resolved "https://registry.yarnpkg.com/dotenv/-/dotenv-16.0.3.tgz#115aec42bac5053db3c456db30cc243a5a836a07" integrity sha512-7GO6HghkA5fYG9TYnNxi14/7K9f5occMlp3zXAuSxn7CKCxt9xbNWG7yF8hTCSUchlfWSe3uLmlPfigevRItzQ== +dotenv@^16.4.4, dotenv@^16.4.5: + version "16.4.5" + resolved "https://registry.yarnpkg.com/dotenv/-/dotenv-16.4.5.tgz#cdd3b3b604cb327e286b4762e13502f717cb099f" + integrity sha512-ZmdL2rui+eB2YwhsWzjInR8LldtZHGDoQ1ugH85ppHKwpUHL7j7rN0Ti9NCnGiQbhaZ11FpR+7ao1dNsmduNUg== + dotenv@~10.0.0: version "10.0.0" resolved "https://registry.yarnpkg.com/dotenv/-/dotenv-10.0.0.tgz#3d4227b8fb95f81096cdd2b66653fb2c7085ba81" @@ -15265,13 +15277,14 @@ koalas@^1.0.2: resolved "https://registry.yarnpkg.com/koalas/-/koalas-1.0.2.tgz#318433f074235db78fae5661a02a8ca53ee295cd" integrity sha1-MYQz8HQjXbePrlZhoCqMpT7ilc0= -kysely-codegen@^0.11.0: - version "0.11.0" - resolved "https://registry.yarnpkg.com/kysely-codegen/-/kysely-codegen-0.11.0.tgz#7506955c4c09201b571d528b42ffec8a1869160b" - integrity sha512-8aklzXygjANshk5BoGSQ0BWukKIoPL4/k1iFWyteGUQ/VtB1GlyrELBZv1GglydjLGECSSVDpsOgEXyWQmuksg== +kysely-codegen@^0.15.0: + version "0.15.0" + resolved "https://registry.yarnpkg.com/kysely-codegen/-/kysely-codegen-0.15.0.tgz#771c0256c24897ea64d5713dc10e40e8a359b96b" + integrity sha512-LPta2nQOyoEPDQ3w/Gsplc+2iyZPAsGvtWoS21VzOB0NDQ0B38Xy1gS8WlbGef542Zdw2eLJHxekud9DzVdNRw== dependencies: chalk "4.1.2" - dotenv "^16.0.3" + dotenv "^16.4.5" + dotenv-expand "^11.0.6" git-diff "^2.0.6" micromatch "^4.0.5" minimist "^1.2.8" From d31856039f1ba78044995d853b2d8ec082785671 Mon Sep 17 00:00:00 2001 From: Matt Krick Date: Tue, 4 Jun 2024 09:36:18 -0700 Subject: [PATCH 04/47] fixup: inserts and updates Signed-off-by: Matt Krick --- packages/server/graphql/mutations/createReflection.ts | 7 +++++-- .../server/graphql/mutations/updateReflectionContent.ts | 2 +- ...n-phaes1.ts => 1717096624786_RetroReflection-phase1.ts} | 4 ++-- 3 files changed, 8 insertions(+), 5 deletions(-) rename packages/server/postgres/migrations/{1717096624786_retroReflection-phaes1.ts => 1717096624786_RetroReflection-phase1.ts} (95%) diff --git a/packages/server/graphql/mutations/createReflection.ts b/packages/server/graphql/mutations/createReflection.ts index 7314f29e21e..07896f16021 100644 --- a/packages/server/graphql/mutations/createReflection.ts +++ b/packages/server/graphql/mutations/createReflection.ts @@ -101,8 +101,11 @@ export default { }) await Promise.all([ - pg.insertInto('RetroReflectionGroup').values(reflectionGroup).execute(), - pg.insertInto('RetroReflection').values(reflection).execute(), + pg + .with('Group', (qc) => qc.insertInto('RetroReflectionGroup').values(reflectionGroup)) + .insertInto('RetroReflection') + .values(reflection) + .execute(), r.table('RetroReflection').insert(reflection).run() ]) diff --git a/packages/server/graphql/mutations/updateReflectionContent.ts b/packages/server/graphql/mutations/updateReflectionContent.ts index 1505b890f30..82db8e06c1e 100644 --- a/packages/server/graphql/mutations/updateReflectionContent.ts +++ b/packages/server/graphql/mutations/updateReflectionContent.ts @@ -91,7 +91,7 @@ export default { .updateTable('RetroReflection') .set({ content: normalizedContent, - entities, + entities: JSON.stringify(entities), sentimentScore, plaintextContent }) diff --git a/packages/server/postgres/migrations/1717096624786_retroReflection-phaes1.ts b/packages/server/postgres/migrations/1717096624786_RetroReflection-phase1.ts similarity index 95% rename from packages/server/postgres/migrations/1717096624786_retroReflection-phaes1.ts rename to packages/server/postgres/migrations/1717096624786_RetroReflection-phase1.ts index 327000ed48c..cb7ce3682f3 100644 --- a/packages/server/postgres/migrations/1717096624786_retroReflection-phaes1.ts +++ b/packages/server/postgres/migrations/1717096624786_RetroReflection-phase1.ts @@ -12,12 +12,12 @@ export async function up() { "isActive" BOOLEAN NOT NULL DEFAULT TRUE, "meetingId" VARCHAR(100) NOT NULL, "promptId" VARCHAR(100) NOT NULL, - "sortOrder" INT NOT NULL DEFAULT 0, + "sortOrder" DOUBLE PRECISION NOT NULL DEFAULT 0, "creatorId" VARCHAR(100) NOT NULL, "content" VARCHAR(2000) NOT NULL, "plaintextContent" VARCHAR(2000) NOT NULL, "entities" JSONB NOT NULL, - "sentimentScore" INT, + "sentimentScore" REAL, "reactjis" JSONB NOT NULL, "reflectionGroupId" VARCHAR(100) NOT NULL, CONSTRAINT "fk_creatorId" From abb3bdfd66820880f7ed3e029c138ae01ecb3e16 Mon Sep 17 00:00:00 2001 From: Matt Krick Date: Tue, 4 Jun 2024 14:23:48 -0700 Subject: [PATCH 05/47] fix: move to record literal types Signed-off-by: Matt Krick --- .../database/types/GoogleAnalyzedEntity.ts | 1 - packages/server/database/types/Reactji.ts | 1 - packages/server/database/types/Reflection.ts | 12 +++- .../graphql/mutations/createReflection.ts | 2 +- .../mutations/helpers/calculateEngagement.ts | 1 + .../removeReflectionFromGroup.ts | 2 +- .../mutations/updateReflectionContent.ts | 3 +- .../private/mutations/hardDeleteUser.ts | 9 +-- .../server/graphql/types/RetroReflection.ts | 2 +- .../1717096624786_RetroReflection-phase1.ts | 71 +++++++++++-------- 10 files changed, 59 insertions(+), 45 deletions(-) diff --git a/packages/server/database/types/GoogleAnalyzedEntity.ts b/packages/server/database/types/GoogleAnalyzedEntity.ts index cea24fa8514..b148d69967e 100644 --- a/packages/server/database/types/GoogleAnalyzedEntity.ts +++ b/packages/server/database/types/GoogleAnalyzedEntity.ts @@ -5,7 +5,6 @@ interface Input { } export default class GoogleAnalyzedEntity { - [key: string]: any lemma?: string name: string salience: number diff --git a/packages/server/database/types/Reactji.ts b/packages/server/database/types/Reactji.ts index d23dec48681..fa36d325351 100644 --- a/packages/server/database/types/Reactji.ts +++ b/packages/server/database/types/Reactji.ts @@ -4,7 +4,6 @@ interface Input { } export default class Reactji { - [key: string]: any userId: string id: string constructor(input: Input) { diff --git a/packages/server/database/types/Reflection.ts b/packages/server/database/types/Reflection.ts index d682e59d4d2..e2d816d8d0c 100644 --- a/packages/server/database/types/Reflection.ts +++ b/packages/server/database/types/Reflection.ts @@ -3,6 +3,9 @@ import generateUID from '../../generateUID' import GoogleAnalyzedEntity from './GoogleAnalyzedEntity' import Reactji from './Reactji' +export const toGoogleAnalyzedEntityPG = (e: GoogleAnalyzedEntity) => + `(${e.name},${e.salience},${e.lemma || null})` + export interface ReflectionInput { id?: string createdAt?: Date @@ -23,7 +26,7 @@ export default class Reflection { id: string createdAt: Date // userId of the creator - creatorId: string + creatorId: string | null content: string plaintextContent: string entities: GoogleAnalyzedEntity[] @@ -67,4 +70,11 @@ export default class Reflection { this.sortOrder = sortOrder || 0 this.updatedAt = updatedAt || now } + toPG() { + return { + ...this, + reactjis: this.reactjis.map((r) => `(${r.id},${r.userId})`), + entities: this.entities.map(toGoogleAnalyzedEntityPG) + } + } } diff --git a/packages/server/graphql/mutations/createReflection.ts b/packages/server/graphql/mutations/createReflection.ts index 07896f16021..61adf5d161d 100644 --- a/packages/server/graphql/mutations/createReflection.ts +++ b/packages/server/graphql/mutations/createReflection.ts @@ -104,7 +104,7 @@ export default { pg .with('Group', (qc) => qc.insertInto('RetroReflectionGroup').values(reflectionGroup)) .insertInto('RetroReflection') - .values(reflection) + .values(reflection.toPG()) .execute(), r.table('RetroReflection').insert(reflection).run() ]) diff --git a/packages/server/graphql/mutations/helpers/calculateEngagement.ts b/packages/server/graphql/mutations/helpers/calculateEngagement.ts index f4416615b2f..3b79f6dcb51 100644 --- a/packages/server/graphql/mutations/helpers/calculateEngagement.ts +++ b/packages/server/graphql/mutations/helpers/calculateEngagement.ts @@ -46,6 +46,7 @@ const calculateEngagement = async (meeting: Meeting, dataLoader: DataLoaderWorke if (getPhase(phases, 'reflect')) { const reflections = await dataLoader.get('retroReflectionsByMeetingId').load(meetingId) reflections.forEach(({creatorId, reactjis}) => { + if (!creatorId) return passiveMembers.delete(creatorId) reactjis.forEach(({userId}) => { passiveMembers.delete(userId) diff --git a/packages/server/graphql/mutations/helpers/updateReflectionLocation/removeReflectionFromGroup.ts b/packages/server/graphql/mutations/helpers/updateReflectionLocation/removeReflectionFromGroup.ts index 59e14fa36af..b54764c94f7 100644 --- a/packages/server/graphql/mutations/helpers/updateReflectionLocation/removeReflectionFromGroup.ts +++ b/packages/server/graphql/mutations/helpers/updateReflectionLocation/removeReflectionFromGroup.ts @@ -45,8 +45,8 @@ const removeReflectionFromGroup = async (reflectionId: string, {dataLoader}: GQL const reflectionGroup = new ReflectionGroup({meetingId, promptId, sortOrder: newSortOrder}) const {id: reflectionGroupId} = reflectionGroup await Promise.all([ - pg.insertInto('RetroReflectionGroup').values(reflectionGroup).execute(), pg + .with('Group', (qc) => qc.insertInto('RetroReflectionGroup').values(reflectionGroup)) .updateTable('RetroReflection') .set({ sortOrder: 0, diff --git a/packages/server/graphql/mutations/updateReflectionContent.ts b/packages/server/graphql/mutations/updateReflectionContent.ts index 82db8e06c1e..2ea810b2478 100644 --- a/packages/server/graphql/mutations/updateReflectionContent.ts +++ b/packages/server/graphql/mutations/updateReflectionContent.ts @@ -6,6 +6,7 @@ import getGroupSmartTitle from 'parabol-client/utils/smartGroup/getGroupSmartTit import normalizeRawDraftJS from 'parabol-client/validation/normalizeRawDraftJS' import stringSimilarity from 'string-similarity' import getRethink from '../../database/rethinkDriver' +import {toGoogleAnalyzedEntityPG} from '../../database/types/Reflection' import getKysely from '../../postgres/getKysely' import {getUserId, isTeamMember} from '../../utils/authorization' import publish from '../../utils/publish' @@ -91,7 +92,7 @@ export default { .updateTable('RetroReflection') .set({ content: normalizedContent, - entities: JSON.stringify(entities), + entities: entities.map(toGoogleAnalyzedEntityPG), sentimentScore, plaintextContent }) diff --git a/packages/server/graphql/private/mutations/hardDeleteUser.ts b/packages/server/graphql/private/mutations/hardDeleteUser.ts index fe8f26951b6..403ccdbf4a2 100644 --- a/packages/server/graphql/private/mutations/hardDeleteUser.ts +++ b/packages/server/graphql/private/mutations/hardDeleteUser.ts @@ -1,7 +1,6 @@ import TeamMemberId from 'parabol-client/shared/gqlIds/TeamMemberId' import getRethink from '../../../database/rethinkDriver' import {RValue} from '../../../database/stricterR' -import getKysely from '../../../postgres/getKysely' import getPg from '../../../postgres/getPg' import {getUserByEmail} from '../../../postgres/queries/getUsersByEmails' import {getUserById} from '../../../postgres/queries/getUsersByIds' @@ -153,7 +152,7 @@ const hardDeleteUser: MutationResolvers['hardDeleteUser'] = async ( retroReflection: r .table('RetroReflection') .getAll(r.args(retroReflectionIds), {index: 'id'}) - .update({creatorId: tombstoneId}), + .update({creatorId: null}), slackNotification: r .table('SlackNotification') .getAll(userIdToDelete, {index: 'userId'}) @@ -199,12 +198,8 @@ const hardDeleteUser: MutationResolvers['hardDeleteUser'] = async ( }).run() // now postgres, after FKs are added then triggers should take care of children + // TODO when we're done migrating to PG, these should have constraints that ON DELETE CASCADE await Promise.all([ - getKysely() - .updateTable('RetroReflection') - .set({creatorId: tombstoneId}) - .where('creatorId', '=', userIdToDelete) - .execute(), pg.query(`DELETE FROM "AtlassianAuth" WHERE "userId" = $1`, [userIdToDelete]), pg.query(`DELETE FROM "GitHubAuth" WHERE "userId" = $1`, [userIdToDelete]), pg.query( diff --git a/packages/server/graphql/types/RetroReflection.ts b/packages/server/graphql/types/RetroReflection.ts index 0555b088fcb..5c10094686c 100644 --- a/packages/server/graphql/types/RetroReflection.ts +++ b/packages/server/graphql/types/RetroReflection.ts @@ -69,7 +69,7 @@ const RetroReflection = new GraphQLObjectType({ // let's not allow super users to grap this in case the UI does not check `disableAnonymity` in which case // reflection authors would be always visible for them - if (meetingType !== 'retrospective' || !meeting.disableAnonymity) { + if (meetingType !== 'retrospective' || !meeting.disableAnonymity || !creatorId) { return null } diff --git a/packages/server/postgres/migrations/1717096624786_RetroReflection-phase1.ts b/packages/server/postgres/migrations/1717096624786_RetroReflection-phase1.ts index cb7ce3682f3..63f54dc2e01 100644 --- a/packages/server/postgres/migrations/1717096624786_RetroReflection-phase1.ts +++ b/packages/server/postgres/migrations/1717096624786_RetroReflection-phase1.ts @@ -5,36 +5,44 @@ export async function up() { const client = new Client(getPgConfig()) await client.connect() await client.query(` - CREATE TABLE IF NOT EXISTS "RetroReflection" ( - "id" VARCHAR(100) PRIMARY KEY, - "createdAt" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(), - "updatedAt" TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP NOT NULL, - "isActive" BOOLEAN NOT NULL DEFAULT TRUE, - "meetingId" VARCHAR(100) NOT NULL, - "promptId" VARCHAR(100) NOT NULL, - "sortOrder" DOUBLE PRECISION NOT NULL DEFAULT 0, - "creatorId" VARCHAR(100) NOT NULL, - "content" VARCHAR(2000) NOT NULL, - "plaintextContent" VARCHAR(2000) NOT NULL, - "entities" JSONB NOT NULL, - "sentimentScore" REAL, - "reactjis" JSONB NOT NULL, - "reflectionGroupId" VARCHAR(100) NOT NULL, - CONSTRAINT "fk_creatorId" - FOREIGN KEY("creatorId") - REFERENCES "User"("id") - ON DELETE CASCADE, - CONSTRAINT "fk_reflectionGroupId" - FOREIGN KEY("reflectionGroupId") - REFERENCES "RetroReflectionGroup"("id") - ON DELETE CASCADE - ); - CREATE INDEX IF NOT EXISTS "idx_RetroReflection_meetingId" ON "RetroReflection"("meetingId"); - CREATE INDEX IF NOT EXISTS "idx_RetroReflection_promptId" ON "RetroReflection"("promptId"); - CREATE INDEX IF NOT EXISTS "idx_RetroReflection_creatorId" ON "RetroReflection"("creatorId"); - CREATE INDEX IF NOT EXISTS "idx_RetroReflection_reflectionGroupId" ON "RetroReflection"("reflectionGroupId"); - DROP TRIGGER IF EXISTS "update_RetroReflection_updatedAt" ON "RetroReflection"; - CREATE TRIGGER "update_RetroReflection_updatedAt" BEFORE UPDATE ON "RetroReflection" FOR EACH ROW EXECUTE PROCEDURE "set_updatedAt"(); + DO $$ + BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'GoogleAnalyzedEntity') THEN + CREATE TYPE "GoogleAnalyzedEntity" AS (name text, salience real, lemma text); + END IF; + + CREATE TABLE IF NOT EXISTS "RetroReflection" ( + "id" VARCHAR(100) PRIMARY KEY, + "createdAt" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(), + "updatedAt" TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP NOT NULL, + "isActive" BOOLEAN NOT NULL DEFAULT TRUE, + "meetingId" VARCHAR(100) NOT NULL, + "promptId" VARCHAR(100) NOT NULL, + "sortOrder" DOUBLE PRECISION NOT NULL DEFAULT 0, + "creatorId" VARCHAR(100), + "content" VARCHAR(2000) NOT NULL, + "plaintextContent" VARCHAR(2000) NOT NULL, + "entities" "GoogleAnalyzedEntity"[] NOT NULL DEFAULT array[]::"GoogleAnalyzedEntity"[], + "sentimentScore" REAL, + "reactjis" "Reactji"[] NOT NULL DEFAULT array[]::"Reactji"[], + "reflectionGroupId" VARCHAR(100) NOT NULL, + CONSTRAINT "fk_creatorId" + FOREIGN KEY("creatorId") + REFERENCES "User"("id") + ON DELETE SET NULL, + CONSTRAINT "fk_reflectionGroupId" + FOREIGN KEY("reflectionGroupId") + REFERENCES "RetroReflectionGroup"("id") + ON DELETE CASCADE + ); + CREATE INDEX IF NOT EXISTS "idx_RetroReflection_meetingId" ON "RetroReflection"("meetingId"); + CREATE INDEX IF NOT EXISTS "idx_RetroReflection_promptId" ON "RetroReflection"("promptId"); + CREATE INDEX IF NOT EXISTS "idx_RetroReflection_creatorId" ON "RetroReflection"("creatorId"); + CREATE INDEX IF NOT EXISTS "idx_RetroReflection_reflectionGroupId" ON "RetroReflection"("reflectionGroupId"); + DROP TRIGGER IF EXISTS "update_RetroReflection_updatedAt" ON "RetroReflection"; + CREATE TRIGGER "update_RetroReflection_updatedAt" BEFORE UPDATE ON "RetroReflection" FOR EACH ROW EXECUTE PROCEDURE "set_updatedAt"(); + END $$; + `) await client.end() } @@ -43,7 +51,8 @@ export async function down() { const client = new Client(getPgConfig()) await client.connect() await client.query(` - DROP TABLE IF EXISTS "RetroReflection"; + DROP TABLE IF EXISTS "RetroReflection"; + DROP TYPE IF EXISTS "GoogleAnalyzedEntity" CASCADE; `) await client.end() } From 991144864e5a288788b0559aa81beb871e9c85a7 Mon Sep 17 00:00:00 2001 From: Matt Krick Date: Tue, 4 Jun 2024 14:56:05 -0700 Subject: [PATCH 06/47] fix types Signed-off-by: Matt Krick --- packages/embedder/indexing/retrospectiveDiscussionTopic.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/embedder/indexing/retrospectiveDiscussionTopic.ts b/packages/embedder/indexing/retrospectiveDiscussionTopic.ts index 47876db69cb..ac49248abfd 100644 --- a/packages/embedder/indexing/retrospectiveDiscussionTopic.ts +++ b/packages/embedder/indexing/retrospectiveDiscussionTopic.ts @@ -22,7 +22,7 @@ import {ISO6391} from '../iso6393To1' const IGNORE_COMMENT_USER_IDS = ['parabolAIUser'] const MAX_TEXT_LENGTH = 10000 -async function getPreferredNameByUserId(userId: string, dataLoader: DataLoaderInstance) { +async function getPreferredNameByUserId(userId: string | null, dataLoader: DataLoaderInstance) { if (!userId) return 'Unknown' const user = await dataLoader.get('users').load(userId) return !user ? 'Unknown' : user.preferredName From af03b310a10a89d6e0c663bfbc8a04efaa8a4714 Mon Sep 17 00:00:00 2001 From: Matt Krick Date: Thu, 6 Jun 2024 11:19:42 -0700 Subject: [PATCH 07/47] chore: migrate reflections to PG --- packages/client/utils/constants.ts | 2 +- .../1714598525167_addFavoriteTemplateIds.ts | 2 +- .../migrations/1716914102795_removeKudos.ts | 16 +- ...1716995191300_allowGlobalOAuth1Provider.ts | 2 +- .../1717606963897_RetroReflection-phase2.ts | 147 ++++++++++++++++++ 5 files changed, 160 insertions(+), 9 deletions(-) create mode 100644 packages/server/postgres/migrations/1717606963897_RetroReflection-phase2.ts diff --git a/packages/client/utils/constants.ts b/packages/client/utils/constants.ts index 5fb9d20ba04..b4c1b3efd4c 100644 --- a/packages/client/utils/constants.ts +++ b/packages/client/utils/constants.ts @@ -5,10 +5,10 @@ - Does the variable come from the GraphQL schema? If so, import it from a file in the __generated__ folder - Is the variable a string? Create a string union & pass in a plain string to get type safety */ -import {TaskStatusEnum} from '~/__generated__/UpdateTaskMutation.graphql' import {ReadableReasonToDowngradeEnum} from '../../server/graphql/types/ReasonToDowngrade' import {ReasonToDowngradeEnum} from '../__generated__/DowngradeToStarterMutation.graphql' import {TimelineEventEnum} from '../__generated__/MyDashboardTimelineQuery.graphql' +import {TaskStatusEnum} from '../__generated__/UpdateTaskMutation.graphql' import {Threshold} from '../types/constEnums' /* Meeting Misc. */ diff --git a/packages/server/postgres/migrations/1714598525167_addFavoriteTemplateIds.ts b/packages/server/postgres/migrations/1714598525167_addFavoriteTemplateIds.ts index b2a17b04b43..73ac53a6b16 100644 --- a/packages/server/postgres/migrations/1714598525167_addFavoriteTemplateIds.ts +++ b/packages/server/postgres/migrations/1714598525167_addFavoriteTemplateIds.ts @@ -10,7 +10,7 @@ export async function up() { await sql` ALTER TABLE "User" - ADD COLUMN "favoriteTemplateIds" TEXT[] NOT NULL DEFAULT ARRAY[]::TEXT[]; + ADD COLUMN IF NOT EXISTS "favoriteTemplateIds" TEXT[] NOT NULL DEFAULT ARRAY[]::TEXT[]; `.execute(pg) } diff --git a/packages/server/postgres/migrations/1716914102795_removeKudos.ts b/packages/server/postgres/migrations/1716914102795_removeKudos.ts index 8bbb41800b6..291997a9d76 100644 --- a/packages/server/postgres/migrations/1716914102795_removeKudos.ts +++ b/packages/server/postgres/migrations/1716914102795_removeKudos.ts @@ -8,12 +8,16 @@ export async function up() { }) }) - await pg.schema.dropTable('Kudos').execute() - await pg.schema - .alterTable('Team') - .dropColumn('giveKudosWithEmoji') - .dropColumn('kudosEmoji') - .execute() + await pg.schema.dropTable('Kudos').ifExists().execute() + try { + await pg.schema + .alterTable('Team') + .dropColumn('giveKudosWithEmoji') + .dropColumn('kudosEmoji') + .execute() + } catch { + // noop + } } export async function down() { diff --git a/packages/server/postgres/migrations/1716995191300_allowGlobalOAuth1Provider.ts b/packages/server/postgres/migrations/1716995191300_allowGlobalOAuth1Provider.ts index 51606b8c040..575c4ad5747 100644 --- a/packages/server/postgres/migrations/1716995191300_allowGlobalOAuth1Provider.ts +++ b/packages/server/postgres/migrations/1716995191300_allowGlobalOAuth1Provider.ts @@ -8,7 +8,7 @@ export async function up() { DO $$ BEGIN ALTER TABLE "IntegrationProvider" - DROP CONSTRAINT global_provider_must_be_oauth2; + DROP CONSTRAINT IF EXISTS global_provider_must_be_oauth2; END $$; `) await client.end() diff --git a/packages/server/postgres/migrations/1717606963897_RetroReflection-phase2.ts b/packages/server/postgres/migrations/1717606963897_RetroReflection-phase2.ts new file mode 100644 index 00000000000..b8fba7b6591 --- /dev/null +++ b/packages/server/postgres/migrations/1717606963897_RetroReflection-phase2.ts @@ -0,0 +1,147 @@ +import {Kysely, PostgresDialect, sql} from 'kysely' +import convertToTaskContent from 'parabol-client/utils/draftjs/convertToTaskContent' +import extractTextFromDraftString from 'parabol-client/utils/draftjs/extractTextFromDraftString' +import {r} from 'rethinkdb-ts' +import connectRethinkDB from '../../database/connectRethinkDB' +import getPg from '../getPg' + +export async function up() { + await connectRethinkDB() + const pg = new Kysely({ + dialect: new PostgresDialect({ + pool: getPg() + }) + }) + try { + await r + .table('RetroReflection') + .indexCreate('updatedAtId', (row: any) => [row('updatedAt'), row('id')]) + .run() + await r.table('RetroReflection').indexWait().run() + } catch { + // index already exists + } + + const MAX_PG_PARAMS = 65545 + + const PG_COLS = [ + 'id', + 'createdAt', + 'updatedAt', + 'isActive', + 'meetingId', + 'promptId', + 'sortOrder', + 'creatorId', + 'content', + 'plaintextContent', + 'entities', + 'sentimentScore', + 'reactjis', + 'reflectionGroupId' + ] as const + type RetroReflection = { + [K in (typeof PG_COLS)[number]]: any + } + const BATCH_SIZE = Math.trunc(MAX_PG_PARAMS / PG_COLS.length) + + const capContent = (content: string, plaintextContent: string) => { + let nextContent = content + let nextPlaintextContent = plaintextContent || extractTextFromDraftString(content) + while (nextContent.length > 2000 || nextPlaintextContent.length > 2000) { + const maxLen = Math.max(nextContent.length, nextPlaintextContent.length) + const overage = maxLen - 2000 + const stopIdx = nextPlaintextContent.length - overage - 1 + nextPlaintextContent = nextPlaintextContent.slice(0, stopIdx) + nextContent = convertToTaskContent(nextPlaintextContent) + } + return {content: nextContent, plaintextContent: nextPlaintextContent} + } + + let curUpdatedAt = r.minval + let curId = r.minval + + for (let i = 0; i < 1e6; i++) { + console.log('inserting row', i * BATCH_SIZE, curUpdatedAt, curId) + const rawRowsToInsert = (await r + .table('RetroReflection') + .between([curUpdatedAt, curId], [r.maxval, r.maxval], { + index: 'updatedAtId', + leftBound: 'open', + rightBound: 'closed' + }) + .orderBy({index: 'updatedAtId'}) + .limit(BATCH_SIZE) + .pluck(...PG_COLS) + .run()) as RetroReflection[] + + const rowsToInsert = rawRowsToInsert.map((row) => { + const nonzeroEntities = row.entities?.length > 0 ? row.entities : undefined + const normalizedEntities = nonzeroEntities?.map((e: any) => ({ + ...e, + salience: typeof e.salience === 'number' ? e.salience : 0 + })) + return { + ...row, + ...capContent(row.content, row.plaintextContent), + reactjis: row.reactjis?.map((r: any) => `(${r.id},${r.userId})`), + entities: normalizedEntities + ? sql`(select array_agg((name, salience, lemma)::"GoogleAnalyzedEntity") from json_populate_recordset(null::"GoogleAnalyzedEntity", ${JSON.stringify(normalizedEntities)}))` + : undefined + } + }) + if (rowsToInsert.length === 0) break + const lastRow = rowsToInsert[rowsToInsert.length - 1] + curUpdatedAt = lastRow.updatedAt + curId = lastRow.id + let isFailure = false + // NOTE: This migration inserts row-by-row because there are so many referential integrity errors + // Do not copy this migration logic for future migrations, it is slow! + const insertSingleRow = async (row: RetroReflection) => { + if (isFailure) return + try { + await pg + .insertInto('RetroReflection') + .values(row) + .onConflict((oc) => oc.doNothing()) + .execute() + } catch (e) { + if (e.constraint === 'fk_reflectionGroupId') { + await pg + .insertInto('RetroReflectionGroup') + .values({ + id: row.reflectionGroupId, + createdAt: row.createdAt, + updatedAt: row.updatedAt, + isActive: row.isActive, + meetingId: row.meetingId, + promptId: row.promptId + }) + // multiple reflections may be trying to create the same group + .onConflict((oc) => oc.doNothing()) + .execute() + await insertSingleRow(row) + } else if (e.constraint === 'fk_creatorId') { + await r.table('RetroReflection').get(row.id).update({creatorId: null}).run() + await insertSingleRow({...row, creatorId: null}) + } else { + isFailure = true + console.log(e, row) + } + } + } + await Promise.all(rowsToInsert.map(insertSingleRow)) + if (isFailure) { + throw 'Failed batch' + } + } +} + +export async function down() { + await connectRethinkDB() + try { + await r.table('RetroReflection').indexDrop('updatedAtId').run() + } catch { + // index already dropped + } +} From 8dfa87025863e4521e4daa4117fb5d1dd404617c Mon Sep 17 00:00:00 2001 From: Matt Krick Date: Thu, 6 Jun 2024 11:22:31 -0700 Subject: [PATCH 08/47] fix: remove hard delete of inactive groups --- .../server/graphql/mutations/helpers/safeEndRetrospective.ts | 5 ----- .../migrations/1717096624786_RetroReflection-phase1.ts | 2 +- 2 files changed, 1 insertion(+), 6 deletions(-) diff --git a/packages/server/graphql/mutations/helpers/safeEndRetrospective.ts b/packages/server/graphql/mutations/helpers/safeEndRetrospective.ts index b902b66c3a0..09e897c6798 100644 --- a/packages/server/graphql/mutations/helpers/safeEndRetrospective.ts +++ b/packages/server/graphql/mutations/helpers/safeEndRetrospective.ts @@ -167,11 +167,6 @@ const safeEndRetrospective = async ({ dataLoader.get('teamMembersByTeamId').load(teamId), removeEmptyTasks(meetingId), dataLoader.get('meetingTemplates').loadNonNull(templateId), - pg - .deleteFrom('RetroReflectionGroup') - .where('meetingId', '=', meetingId) - .where('isActive', '=', false) - .execute(), updateTeamInsights(teamId, dataLoader) ]) // wait for removeEmptyTasks before summarizeRetroMeeting diff --git a/packages/server/postgres/migrations/1717096624786_RetroReflection-phase1.ts b/packages/server/postgres/migrations/1717096624786_RetroReflection-phase1.ts index 63f54dc2e01..fbc692e7cac 100644 --- a/packages/server/postgres/migrations/1717096624786_RetroReflection-phase1.ts +++ b/packages/server/postgres/migrations/1717096624786_RetroReflection-phase1.ts @@ -17,7 +17,7 @@ export async function up() { "updatedAt" TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP NOT NULL, "isActive" BOOLEAN NOT NULL DEFAULT TRUE, "meetingId" VARCHAR(100) NOT NULL, - "promptId" VARCHAR(100) NOT NULL, + "promptId" VARCHAR(111) NOT NULL, "sortOrder" DOUBLE PRECISION NOT NULL DEFAULT 0, "creatorId" VARCHAR(100), "content" VARCHAR(2000) NOT NULL, From 24da04bda0db14e8e24065eefb9465e9eb1f560a Mon Sep 17 00:00:00 2001 From: Matt Krick Date: Thu, 6 Jun 2024 11:31:18 -0700 Subject: [PATCH 09/47] fixup: remove unused var Signed-off-by: Matt Krick --- .../server/graphql/mutations/helpers/safeEndRetrospective.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/packages/server/graphql/mutations/helpers/safeEndRetrospective.ts b/packages/server/graphql/mutations/helpers/safeEndRetrospective.ts index 09e897c6798..a80d9cf8936 100644 --- a/packages/server/graphql/mutations/helpers/safeEndRetrospective.ts +++ b/packages/server/graphql/mutations/helpers/safeEndRetrospective.ts @@ -7,7 +7,6 @@ import getRethink from '../../../database/rethinkDriver' import {RDatum} from '../../../database/stricterR' import MeetingRetrospective from '../../../database/types/MeetingRetrospective' import TimelineEventRetroComplete from '../../../database/types/TimelineEventRetroComplete' -import getKysely from '../../../postgres/getKysely' import removeSuggestedAction from '../../../safeMutations/removeSuggestedAction' import {Logger} from '../../../utils/Logger' import RecallAIServerManager from '../../../utils/RecallAIServerManager' @@ -123,7 +122,6 @@ const safeEndRetrospective = async ({ const {authToken, socketId: mutatorId, dataLoader} = context const {id: meetingId, phases, facilitatorStageId, teamId} = meeting const r = await getRethink() - const pg = getKysely() const operationId = dataLoader.share() const subOptions = {mutatorId, operationId} const viewerId = getUserId(authToken) From 0d2c7d732017f2ded143abbcb963d6c4af382a5a Mon Sep 17 00:00:00 2001 From: Matt Krick Date: Thu, 6 Jun 2024 12:53:19 -0700 Subject: [PATCH 10/47] handle escape chars and commas Signed-off-by: Matt Krick --- packages/server/database/types/Reflection.ts | 10 +++++++--- .../graphql/mutations/updateReflectionContent.ts | 2 +- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/packages/server/database/types/Reflection.ts b/packages/server/database/types/Reflection.ts index e2d816d8d0c..93543c8b7e2 100644 --- a/packages/server/database/types/Reflection.ts +++ b/packages/server/database/types/Reflection.ts @@ -1,10 +1,13 @@ +import {sql} from 'kysely' import extractTextFromDraftString from 'parabol-client/utils/draftjs/extractTextFromDraftString' import generateUID from '../../generateUID' import GoogleAnalyzedEntity from './GoogleAnalyzedEntity' import Reactji from './Reactji' -export const toGoogleAnalyzedEntityPG = (e: GoogleAnalyzedEntity) => - `(${e.name},${e.salience},${e.lemma || null})` +export const toGoogleAnalyzedEntityPG = (entities: GoogleAnalyzedEntity[]) => + sql< + string[] + >`(select coalesce(array_agg((name, salience, lemma)::"GoogleAnalyzedEntity"), '{}') from json_populate_recordset(null::"GoogleAnalyzedEntity", ${JSON.stringify(entities)}))` export interface ReflectionInput { id?: string @@ -74,7 +77,8 @@ export default class Reflection { return { ...this, reactjis: this.reactjis.map((r) => `(${r.id},${r.userId})`), - entities: this.entities.map(toGoogleAnalyzedEntityPG) + // this is complex because we have to account for escape chars. it's safest to pass in a JSON object & let PG do the conversion for us + entities: toGoogleAnalyzedEntityPG(this.entities) } } } diff --git a/packages/server/graphql/mutations/updateReflectionContent.ts b/packages/server/graphql/mutations/updateReflectionContent.ts index 2ea810b2478..fb32bbd283e 100644 --- a/packages/server/graphql/mutations/updateReflectionContent.ts +++ b/packages/server/graphql/mutations/updateReflectionContent.ts @@ -92,7 +92,7 @@ export default { .updateTable('RetroReflection') .set({ content: normalizedContent, - entities: entities.map(toGoogleAnalyzedEntityPG), + entities: toGoogleAnalyzedEntityPG(entities), sentimentScore, plaintextContent }) From 9ef9346e37b10bb63b20e2eefe7a5d9d665bbd48 Mon Sep 17 00:00:00 2001 From: Matt Krick Date: Thu, 6 Jun 2024 14:56:46 -0700 Subject: [PATCH 11/47] chore: begin building equality checker Signed-off-by: Matt Krick --- .../mutations/checkRethinkPgEquality.ts | 31 ++++++++--- .../postgres/utils/rethinkEqualityFns.ts | 55 ++++++++++++++++++- 2 files changed, 77 insertions(+), 9 deletions(-) diff --git a/packages/server/graphql/private/mutations/checkRethinkPgEquality.ts b/packages/server/graphql/private/mutations/checkRethinkPgEquality.ts index 932c9bccf40..042ac1b6022 100644 --- a/packages/server/graphql/private/mutations/checkRethinkPgEquality.ts +++ b/packages/server/graphql/private/mutations/checkRethinkPgEquality.ts @@ -4,7 +4,12 @@ import getKysely from '../../../postgres/getKysely' import {checkRowCount, checkTableEq} from '../../../postgres/utils/checkEqBase' import { compareDateAlmostEqual, + compareOptionalPlaintextContent, + compareRValOptionalPluckedArray, + compareRValUndefinedAsEmptyArray, + compareRValUndefinedAsNull, compareRValUndefinedAsNullAndTruncateRVal, + compareRealNumber, defaultEqFn } from '../../../postgres/utils/rethinkEqualityFns' import {MutationResolvers} from '../resolverTypes' @@ -31,11 +36,11 @@ const checkRethinkPgEquality: MutationResolvers['checkRethinkPgEquality'] = asyn ) => { const r = await getRethink() - if (tableName === 'RetroReflectionGroup') { + if (tableName === 'RetroReflection') { const rowCountResult = await checkRowCount(tableName) const rethinkQuery = (updatedAt: Date, id: string | number) => { return r - .table('RetroReflectionGroup' as any) + .table('RetroReflection') .between([updatedAt, id], [r.maxval, r.maxval], { index: 'updatedAtId', leftBound: 'open', @@ -45,8 +50,12 @@ const checkRethinkPgEquality: MutationResolvers['checkRethinkPgEquality'] = asyn } const pgQuery = (ids: string[]) => { return getKysely() - .selectFrom('RetroReflectionGroup') + .selectFrom('RetroReflection') .selectAll() + .select(({fn}) => [ + fn('to_json', ['entities']).as('entities'), + fn('to_json', ['reactjis']).as('reactjis') + ]) .where('id', 'in', ids) .execute() } @@ -57,12 +66,18 @@ const checkRethinkPgEquality: MutationResolvers['checkRethinkPgEquality'] = asyn isActive: defaultEqFn, meetingId: defaultEqFn, promptId: defaultEqFn, + creatorId: defaultEqFn, sortOrder: defaultEqFn, - voterIds: defaultEqFn, - smartTitle: compareRValUndefinedAsNullAndTruncateRVal(255), - title: compareRValUndefinedAsNullAndTruncateRVal(255), - summary: compareRValUndefinedAsNullAndTruncateRVal(2000), - discussionPromptQuestion: compareRValUndefinedAsNullAndTruncateRVal(2000) + reflectionGroupId: defaultEqFn, + content: compareRValUndefinedAsNullAndTruncateRVal(2000, 0.98), + plaintextContent: compareOptionalPlaintextContent, + entities: compareRValOptionalPluckedArray({ + name: defaultEqFn, + salience: compareRealNumber, + lemma: compareRValUndefinedAsNull + }), + reactjis: compareRValUndefinedAsEmptyArray, + sentimentScore: compareRValUndefinedAsNull }) return handleResult(tableName, rowCountResult, errors, writeToFile) } diff --git a/packages/server/postgres/utils/rethinkEqualityFns.ts b/packages/server/postgres/utils/rethinkEqualityFns.ts index 69f0ec2bb20..86c0fb08ac0 100644 --- a/packages/server/postgres/utils/rethinkEqualityFns.ts +++ b/packages/server/postgres/utils/rethinkEqualityFns.ts @@ -1,4 +1,5 @@ import isValidDate from 'parabol-client/utils/isValidDate' +import stringSimilarity from 'string-similarity' export const defaultEqFn = (a: unknown, b: unknown) => { if (a instanceof Date && b instanceof Date) return a.getTime() === b.getTime() @@ -12,6 +13,15 @@ export const compareDateAlmostEqual = (rVal: unknown, pgVal: unknown) => { } return false } + +export const compareRealNumber = (rVal: unknown, pgVal: unknown) => { + if (typeof rVal !== 'number' || typeof pgVal !== 'number') return false + // real numbers are 4 bytes & guarantee 6-decimal places of precision + const answer = Math.abs(rVal - pgVal) < 1e-6 + if (!answer) console.log('rVal', rVal, 'pgVal', pgVal) + return answer +} + export const compareRValUndefinedAsNull = (rVal: unknown, pgVal: unknown) => { const normalizedRVal = rVal === undefined ? null : rVal return defaultEqFn(normalizedRVal, pgVal) @@ -21,9 +31,52 @@ export const compareRValUndefinedAsFalse = (rVal: unknown, pgVal: unknown) => { return normalizedRVal === pgVal } +export const compareRValUndefinedAsEmptyArray = (rVal: unknown, pgVal: unknown) => { + const normalizedRVal = rVal === undefined ? [] : rVal + return defaultEqFn(normalizedRVal, pgVal) +} + +export const compareRValOptionalPluckedArray = + (pluckFields: Record) => (rVal: unknown, pgVal: unknown) => { + const rValArray = Array.isArray(rVal) ? rVal : [] + if (!Array.isArray(pgVal)) return false + let isEqual = true + rValArray.forEach((rValItem, idx) => { + const isEqualItem = Object.keys(pluckFields).every((prop) => { + const eqFn = pluckFields[prop]! + const rValItemProp = rValItem[prop] + const pgValItem = pgVal[idx] + const pgValItemProp = pgValItem[prop] + return eqFn(rValItemProp, pgValItemProp) + }) + if (!isEqualItem) { + isEqual = false + } + }) + return isEqual + } + export const compareRValUndefinedAsNullAndTruncateRVal = - (length: number) => (rVal: unknown, pgVal: unknown) => { + (length: number, similarity?: number) => (rVal: unknown, pgVal: unknown) => { const truncatedRVal = typeof rVal === 'string' ? rVal.slice(0, length) : rVal const normalizedRVal = truncatedRVal === undefined ? null : truncatedRVal + if ( + typeof normalizedRVal === 'string' && + typeof pgVal === 'string' && + similarity && + similarity < 1 + ) { + if (normalizedRVal === pgVal) return true + const comparison = stringSimilarity.compareTwoStrings(normalizedRVal, pgVal) + console.log('compare', comparison, normalizedRVal, pgVal) + return comparison >= similarity + } return defaultEqFn(normalizedRVal, pgVal) } + +export const compareOptionalPlaintextContent = (rVal: unknown, pgVal: unknown) => { + // old records don't have a plaintextContent, but we created that in new versions + return rVal === undefined + ? true + : compareRValUndefinedAsNullAndTruncateRVal(2000, 0.98)(rVal, pgVal) +} From 76aeff91d855d088a9b7e5c964b523a156371030 Mon Sep 17 00:00:00 2001 From: Matt Krick Date: Fri, 7 Jun 2024 11:12:10 -0700 Subject: [PATCH 12/47] fixup: account for lots of formatting in content Signed-off-by: Matt Krick --- .../migrations/1717606963897_RetroReflection-phase2.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/server/postgres/migrations/1717606963897_RetroReflection-phase2.ts b/packages/server/postgres/migrations/1717606963897_RetroReflection-phase2.ts index b8fba7b6591..d79b3cde247 100644 --- a/packages/server/postgres/migrations/1717606963897_RetroReflection-phase2.ts +++ b/packages/server/postgres/migrations/1717606963897_RetroReflection-phase2.ts @@ -46,8 +46,9 @@ export async function up() { const BATCH_SIZE = Math.trunc(MAX_PG_PARAMS / PG_COLS.length) const capContent = (content: string, plaintextContent: string) => { - let nextContent = content let nextPlaintextContent = plaintextContent || extractTextFromDraftString(content) + // if they got out of hand with formatting, extract the text & convert it back + let nextContent = content.length <= 2000 ? content : convertToTaskContent(nextPlaintextContent) while (nextContent.length > 2000 || nextPlaintextContent.length > 2000) { const maxLen = Math.max(nextContent.length, nextPlaintextContent.length) const overage = maxLen - 2000 From a12b08a5bc42c9ba535ae0ffda761e9d7c856db6 Mon Sep 17 00:00:00 2001 From: Matt Krick Date: Mon, 24 Jun 2024 13:52:00 -0700 Subject: [PATCH 13/47] fix: rename extra spaces for conversion from plaintext to content Signed-off-by: Matt Krick --- .../mutations/checkRethinkPgEquality.ts | 2 +- .../1717606963897_RetroReflection-phase2.ts | 24 ++++++++++++++++--- .../postgres/utils/rethinkEqualityFns.ts | 3 +-- 3 files changed, 23 insertions(+), 6 deletions(-) diff --git a/packages/server/graphql/private/mutations/checkRethinkPgEquality.ts b/packages/server/graphql/private/mutations/checkRethinkPgEquality.ts index 042ac1b6022..afc3ed1c2ed 100644 --- a/packages/server/graphql/private/mutations/checkRethinkPgEquality.ts +++ b/packages/server/graphql/private/mutations/checkRethinkPgEquality.ts @@ -69,7 +69,7 @@ const checkRethinkPgEquality: MutationResolvers['checkRethinkPgEquality'] = asyn creatorId: defaultEqFn, sortOrder: defaultEqFn, reflectionGroupId: defaultEqFn, - content: compareRValUndefinedAsNullAndTruncateRVal(2000, 0.98), + content: compareRValUndefinedAsNullAndTruncateRVal(2000, 0.19), plaintextContent: compareOptionalPlaintextContent, entities: compareRValOptionalPluckedArray({ name: defaultEqFn, diff --git a/packages/server/postgres/migrations/1717606963897_RetroReflection-phase2.ts b/packages/server/postgres/migrations/1717606963897_RetroReflection-phase2.ts index d79b3cde247..a8969dc6053 100644 --- a/packages/server/postgres/migrations/1717606963897_RetroReflection-phase2.ts +++ b/packages/server/postgres/migrations/1717606963897_RetroReflection-phase2.ts @@ -1,10 +1,22 @@ +import {ContentState, convertToRaw} from 'draft-js' import {Kysely, PostgresDialect, sql} from 'kysely' -import convertToTaskContent from 'parabol-client/utils/draftjs/convertToTaskContent' import extractTextFromDraftString from 'parabol-client/utils/draftjs/extractTextFromDraftString' import {r} from 'rethinkdb-ts' import connectRethinkDB from '../../database/connectRethinkDB' import getPg from '../getPg' +const convertTextToRaw = (text: string) => { + // plaintextContent can have a bunch of linebreaks like \n which get converted into new blocks. + // New blocks take up a BUNCH of space, so we'd rather preserve as much plaintextContent as possible. + const spaceFreeText = text + .split(/\s/) + .filter((s) => s.length) + .join(' ') + const contentState = ContentState.createFromText(spaceFreeText) + const raw = convertToRaw(contentState) + return JSON.stringify(raw) +} + export async function up() { await connectRethinkDB() const pg = new Kysely({ @@ -48,13 +60,13 @@ export async function up() { const capContent = (content: string, plaintextContent: string) => { let nextPlaintextContent = plaintextContent || extractTextFromDraftString(content) // if they got out of hand with formatting, extract the text & convert it back - let nextContent = content.length <= 2000 ? content : convertToTaskContent(nextPlaintextContent) + let nextContent = content.length <= 2000 ? content : convertTextToRaw(nextPlaintextContent) while (nextContent.length > 2000 || nextPlaintextContent.length > 2000) { const maxLen = Math.max(nextContent.length, nextPlaintextContent.length) const overage = maxLen - 2000 const stopIdx = nextPlaintextContent.length - overage - 1 nextPlaintextContent = nextPlaintextContent.slice(0, stopIdx) - nextContent = convertToTaskContent(nextPlaintextContent) + nextContent = convertTextToRaw(nextPlaintextContent) } return {content: nextContent, plaintextContent: nextPlaintextContent} } @@ -145,4 +157,10 @@ export async function down() { } catch { // index already dropped } + const pg = new Kysely({ + dialect: new PostgresDialect({ + pool: getPg() + }) + }) + await pg.deleteFrom('RetroReflection').execute() } diff --git a/packages/server/postgres/utils/rethinkEqualityFns.ts b/packages/server/postgres/utils/rethinkEqualityFns.ts index 86c0fb08ac0..6966e6789bb 100644 --- a/packages/server/postgres/utils/rethinkEqualityFns.ts +++ b/packages/server/postgres/utils/rethinkEqualityFns.ts @@ -68,7 +68,6 @@ export const compareRValUndefinedAsNullAndTruncateRVal = ) { if (normalizedRVal === pgVal) return true const comparison = stringSimilarity.compareTwoStrings(normalizedRVal, pgVal) - console.log('compare', comparison, normalizedRVal, pgVal) return comparison >= similarity } return defaultEqFn(normalizedRVal, pgVal) @@ -78,5 +77,5 @@ export const compareOptionalPlaintextContent = (rVal: unknown, pgVal: unknown) = // old records don't have a plaintextContent, but we created that in new versions return rVal === undefined ? true - : compareRValUndefinedAsNullAndTruncateRVal(2000, 0.98)(rVal, pgVal) + : compareRValUndefinedAsNullAndTruncateRVal(2000, 0.19)(rVal, pgVal) } From a732657694e697d782b173c4c0926a659a853b1b Mon Sep 17 00:00:00 2001 From: Matt Krick Date: Mon, 24 Jun 2024 14:09:07 -0700 Subject: [PATCH 14/47] fix: rename Reactji composite type attributes Signed-off-by: Matt Krick --- .../1719262354561_reactji-rename.ts | 20 +++++++++++++++++++ .../queries/getTeamPromptResponsesByIds.ts | 6 +----- 2 files changed, 21 insertions(+), 5 deletions(-) create mode 100644 packages/server/postgres/migrations/1719262354561_reactji-rename.ts diff --git a/packages/server/postgres/migrations/1719262354561_reactji-rename.ts b/packages/server/postgres/migrations/1719262354561_reactji-rename.ts new file mode 100644 index 00000000000..21461808e6e --- /dev/null +++ b/packages/server/postgres/migrations/1719262354561_reactji-rename.ts @@ -0,0 +1,20 @@ +import {Client} from 'pg' +import getPgConfig from '../getPgConfig' + +export async function up() { + const client = new Client(getPgConfig()) + await client.connect() + + await client.query(` + ALTER TYPE "Reactji" RENAME ATTRIBUTE "userid" to "userId"; + ALTER TYPE "Reactji" RENAME ATTRIBUTE "shortname" to "id"; + `) + await client.end() +} + +export async function down() { + const client = new Client(getPgConfig()) + await client.connect() + await client.query(`` /* Do undo magic */) + await client.end() +} diff --git a/packages/server/postgres/queries/getTeamPromptResponsesByIds.ts b/packages/server/postgres/queries/getTeamPromptResponsesByIds.ts index a3337faa3fa..118de592e96 100644 --- a/packages/server/postgres/queries/getTeamPromptResponsesByIds.ts +++ b/packages/server/postgres/queries/getTeamPromptResponsesByIds.ts @@ -21,11 +21,7 @@ export const mapToTeamPromptResponse = ( return results.map((teamPromptResponse: any) => { return { ...teamPromptResponse, - id: TeamPromptResponseId.join(teamPromptResponse.id), - reactjis: teamPromptResponse.reactjis.map( - (reactji: {shortname: string; userid: string}) => - new Reactji({id: reactji.shortname, userId: reactji.userid}) - ) + id: TeamPromptResponseId.join(teamPromptResponse.id) } as TeamPromptResponse }) } From 954503d95427d7cb566e1a6839b0ba0a6f7b914b Mon Sep 17 00:00:00 2001 From: Matt Krick Date: Mon, 24 Jun 2024 17:05:29 -0700 Subject: [PATCH 15/47] fix: constructor prop in lookup table Signed-off-by: Matt Krick --- .../sanitizeAnalyzedEntititesResponse.ts | 3 +- .../mutations/checkRethinkPgEquality.ts | 60 ++++++++++++------- packages/server/postgres/utils/checkEqBase.ts | 3 +- .../postgres/utils/rethinkEqualityFns.ts | 2 +- 4 files changed, 42 insertions(+), 26 deletions(-) diff --git a/packages/server/graphql/mutations/helpers/autoGroup/sanitizeAnalyzedEntititesResponse.ts b/packages/server/graphql/mutations/helpers/autoGroup/sanitizeAnalyzedEntititesResponse.ts index 40ef591a556..ac5f294691b 100644 --- a/packages/server/graphql/mutations/helpers/autoGroup/sanitizeAnalyzedEntititesResponse.ts +++ b/packages/server/graphql/mutations/helpers/autoGroup/sanitizeAnalyzedEntititesResponse.ts @@ -4,7 +4,8 @@ const sanitizeAnalyzedEntitiesResponse = (response: GoogleAnalyzedEntities | nul if (!response) return null const {entities} = response if (!Array.isArray(entities)) return null - const validEntities = {} as {[lowerCaseName: string]: number} + // very important to Object.create(null) since validEntities['constructor'] would return a function! + const validEntities = Object.create(null) as {[lowerCaseName: string]: number} entities.forEach((entity) => { const {name, salience} = entity if (!name || !salience) return diff --git a/packages/server/graphql/private/mutations/checkRethinkPgEquality.ts b/packages/server/graphql/private/mutations/checkRethinkPgEquality.ts index afc3ed1c2ed..5353de7200c 100644 --- a/packages/server/graphql/private/mutations/checkRethinkPgEquality.ts +++ b/packages/server/graphql/private/mutations/checkRethinkPgEquality.ts @@ -32,7 +32,7 @@ const handleResult = async ( const checkRethinkPgEquality: MutationResolvers['checkRethinkPgEquality'] = async ( _source, - {tableName, writeToFile} + {tableName, writeToFile, maxErrors} ) => { const r = await getRethink() @@ -48,8 +48,8 @@ const checkRethinkPgEquality: MutationResolvers['checkRethinkPgEquality'] = asyn }) .orderBy({index: 'updatedAtId'}) as any } - const pgQuery = (ids: string[]) => { - return getKysely() + const pgQuery = async (ids: string[]) => { + const res = await getKysely() .selectFrom('RetroReflection') .selectAll() .select(({fn}) => [ @@ -58,27 +58,41 @@ const checkRethinkPgEquality: MutationResolvers['checkRethinkPgEquality'] = asyn ]) .where('id', 'in', ids) .execute() + return res.map((row) => ({ + ...row, + reactjis: (row.reactjis as any as {shortname: string; userid: string}[])?.map( + (reactji) => ({ + id: reactji.shortname, + userId: reactji.userid + }) + ) + })) } - const errors = await checkTableEq(rethinkQuery, pgQuery, { - id: defaultEqFn, - createdAt: defaultEqFn, - updatedAt: compareDateAlmostEqual, - isActive: defaultEqFn, - meetingId: defaultEqFn, - promptId: defaultEqFn, - creatorId: defaultEqFn, - sortOrder: defaultEqFn, - reflectionGroupId: defaultEqFn, - content: compareRValUndefinedAsNullAndTruncateRVal(2000, 0.19), - plaintextContent: compareOptionalPlaintextContent, - entities: compareRValOptionalPluckedArray({ - name: defaultEqFn, - salience: compareRealNumber, - lemma: compareRValUndefinedAsNull - }), - reactjis: compareRValUndefinedAsEmptyArray, - sentimentScore: compareRValUndefinedAsNull - }) + const errors = await checkTableEq( + rethinkQuery, + pgQuery, + { + id: defaultEqFn, + createdAt: defaultEqFn, + updatedAt: compareDateAlmostEqual, + isActive: defaultEqFn, + meetingId: defaultEqFn, + promptId: defaultEqFn, + creatorId: compareRValUndefinedAsNull, + sortOrder: defaultEqFn, + reflectionGroupId: defaultEqFn, + content: compareRValUndefinedAsNullAndTruncateRVal(2000, 0.19), + plaintextContent: compareOptionalPlaintextContent, + entities: compareRValOptionalPluckedArray({ + name: defaultEqFn, + salience: compareRealNumber, + lemma: compareRValUndefinedAsNull + }), + reactjis: compareRValUndefinedAsEmptyArray, + sentimentScore: compareRValUndefinedAsNull + }, + maxErrors + ) return handleResult(tableName, rowCountResult, errors, writeToFile) } return 'Table not found' diff --git a/packages/server/postgres/utils/checkEqBase.ts b/packages/server/postgres/utils/checkEqBase.ts index 20bc9914f43..175d20a57a5 100644 --- a/packages/server/postgres/utils/checkEqBase.ts +++ b/packages/server/postgres/utils/checkEqBase.ts @@ -36,8 +36,9 @@ export async function checkTableEq( rethinkQuery: (updatedAt: Date, id: string | number) => RSelection, pgQuery: (ids: string[]) => Promise, equalityMap: Record boolean>, - maxErrors = 10 + maxErrors: number | null | undefined ) { + maxErrors = maxErrors || 10 const batchSize = 3000 const errors = [] as Diff[] const propsToCheck = Object.keys(equalityMap) diff --git a/packages/server/postgres/utils/rethinkEqualityFns.ts b/packages/server/postgres/utils/rethinkEqualityFns.ts index 6966e6789bb..8d8477de41a 100644 --- a/packages/server/postgres/utils/rethinkEqualityFns.ts +++ b/packages/server/postgres/utils/rethinkEqualityFns.ts @@ -39,7 +39,7 @@ export const compareRValUndefinedAsEmptyArray = (rVal: unknown, pgVal: unknown) export const compareRValOptionalPluckedArray = (pluckFields: Record) => (rVal: unknown, pgVal: unknown) => { const rValArray = Array.isArray(rVal) ? rVal : [] - if (!Array.isArray(pgVal)) return false + if (!Array.isArray(pgVal) || pgVal.length !== rValArray.length) return false let isEqual = true rValArray.forEach((rValItem, idx) => { const isEqualItem = Object.keys(pluckFields).every((prop) => { From c7775adc05497aa94f9ffe1531cd694964a040c9 Mon Sep 17 00:00:00 2001 From: Matt Krick Date: Mon, 24 Jun 2024 17:15:18 -0700 Subject: [PATCH 16/47] handle reactji migration Signed-off-by: Matt Krick --- .../private/mutations/checkRethinkPgEquality.ts | 11 +---------- packages/server/postgres/utils/rethinkEqualityFns.ts | 1 - 2 files changed, 1 insertion(+), 11 deletions(-) diff --git a/packages/server/graphql/private/mutations/checkRethinkPgEquality.ts b/packages/server/graphql/private/mutations/checkRethinkPgEquality.ts index 5353de7200c..60a38343f61 100644 --- a/packages/server/graphql/private/mutations/checkRethinkPgEquality.ts +++ b/packages/server/graphql/private/mutations/checkRethinkPgEquality.ts @@ -49,7 +49,7 @@ const checkRethinkPgEquality: MutationResolvers['checkRethinkPgEquality'] = asyn .orderBy({index: 'updatedAtId'}) as any } const pgQuery = async (ids: string[]) => { - const res = await getKysely() + return getKysely() .selectFrom('RetroReflection') .selectAll() .select(({fn}) => [ @@ -58,15 +58,6 @@ const checkRethinkPgEquality: MutationResolvers['checkRethinkPgEquality'] = asyn ]) .where('id', 'in', ids) .execute() - return res.map((row) => ({ - ...row, - reactjis: (row.reactjis as any as {shortname: string; userid: string}[])?.map( - (reactji) => ({ - id: reactji.shortname, - userId: reactji.userid - }) - ) - })) } const errors = await checkTableEq( rethinkQuery, diff --git a/packages/server/postgres/utils/rethinkEqualityFns.ts b/packages/server/postgres/utils/rethinkEqualityFns.ts index 8d8477de41a..8a7282be5f4 100644 --- a/packages/server/postgres/utils/rethinkEqualityFns.ts +++ b/packages/server/postgres/utils/rethinkEqualityFns.ts @@ -18,7 +18,6 @@ export const compareRealNumber = (rVal: unknown, pgVal: unknown) => { if (typeof rVal !== 'number' || typeof pgVal !== 'number') return false // real numbers are 4 bytes & guarantee 6-decimal places of precision const answer = Math.abs(rVal - pgVal) < 1e-6 - if (!answer) console.log('rVal', rVal, 'pgVal', pgVal) return answer } From d53aca41d18350c011aea0dd9926286293e89f0e Mon Sep 17 00:00:00 2001 From: Matt Krick Date: Tue, 25 Jun 2024 11:52:35 -0700 Subject: [PATCH 17/47] change dataloaders to pg Signed-off-by: Matt Krick --- codegen.json | 3 +- .../modules/demo/ClientGraphQLServer.ts | 10 +- packages/client/types/generics.ts | 8 +- .../utils/smartGroup/groupReflections.ts | 3 +- packages/server/database/rethinkDriver.ts | 20 --- .../database/types/GoogleAnalyzedEntity.ts | 7 + packages/server/database/types/Reactable.ts | 4 +- packages/server/database/types/Reflection.ts | 84 --------- .../dataloader/foreignKeyLoaderMakers.ts | 23 +++ .../dataloader/primaryKeyLoaderMakers.ts | 26 +++ .../rethinkForeignKeyLoaderMakers.ts | 28 --- .../rethinkPrimaryKeyLoaderMakers.ts | 2 - .../graphql/mutations/createReflection.ts | 25 ++- .../mutations/helpers/generateGroups.ts | 4 +- .../helpers/removeEmptyReflections.ts | 9 - .../addReflectionToGroup.ts | 29 +-- .../removeReflectionFromGroup.ts | 10 -- .../graphql/mutations/removeReflection.ts | 21 +-- .../mutations/updateReflectionContent.ts | 39 ++-- .../private/mutations/backupOrganization.ts | 11 -- .../mutations/checkRethinkPgEquality.ts | 2 +- .../private/mutations/hardDeleteUser.ts | 112 +++++------- .../public/mutations/addReactjiToReactable.ts | 7 +- .../graphql/public/typeDefs/Reactji.graphql | 24 +++ .../public/typeDefs/RetroReflection.graphql | 105 +++++++++++ .../graphql/public/typeDefs/_legacy.graphql | 121 ------------- .../graphql/public/types/NotifyMentioned.ts | 6 +- .../server/graphql/public/types/Reactji.ts | 18 ++ .../graphql/public/types/RetroReflection.ts | 65 +++++++ packages/server/graphql/resolvers.ts | 2 +- packages/server/graphql/types/Reactji.ts | 42 +---- .../server/graphql/types/RetroReflection.ts | 167 +----------------- .../graphql/types/RetroReflectionGroup.ts | 7 +- packages/server/graphql/types/User.ts | 5 +- packages/server/utils/OpenAIServerManager.ts | 4 +- packages/server/utils/getGroupedReactjis.ts | 4 +- 36 files changed, 389 insertions(+), 668 deletions(-) delete mode 100644 packages/server/database/types/Reflection.ts create mode 100644 packages/server/graphql/public/typeDefs/Reactji.graphql create mode 100644 packages/server/graphql/public/typeDefs/RetroReflection.graphql create mode 100644 packages/server/graphql/public/types/Reactji.ts create mode 100644 packages/server/graphql/public/types/RetroReflection.ts diff --git a/codegen.json b/codegen.json index 9354c2d6426..fa006c77adb 100644 --- a/codegen.json +++ b/codegen.json @@ -97,6 +97,7 @@ "PokerMeetingMember": "../../database/types/MeetingPokerMeetingMember#default as PokerMeetingMemberDB", "RRule": "rrule#RRule", "Reactable": "../../database/types/Reactable#Reactable", + "Reactji": "../types/Reactji#ReactjiSource", "ReflectPrompt": "../../database/types/RetrospectivePrompt#default", "ReflectTemplate": "../../database/types/ReflectTemplate#default", "RemoveApprovedOrganizationDomainsSuccess": "./types/RemoveApprovedOrganizationDomainsSuccess#RemoveApprovedOrganizationDomainsSuccessSource", @@ -104,7 +105,7 @@ "RemoveTeamMemberPayload": "./types/RemoveTeamMemberPayload#RemoveTeamMemberPayloadSource", "RequestToJoinDomainSuccess": "./types/RequestToJoinDomainSuccess#RequestToJoinDomainSuccessSource", "ResetReflectionGroupsSuccess": "./types/ResetReflectionGroupsSuccess#ResetReflectionGroupsSuccessSource", - "RetroReflection": "../../database/types/RetroReflection#default as RetroReflectionDB", + "RetroReflection": "./types/RetroReflection#RetroReflectionSource", "RetroReflectionGroup": "./types/RetroReflectionGroup#RetroReflectionGroupSource", "RetrospectiveMeeting": "../../database/types/MeetingRetrospective#default", "RetrospectiveMeetingMember": "../../database/types/RetroMeetingMember#default", diff --git a/packages/client/modules/demo/ClientGraphQLServer.ts b/packages/client/modules/demo/ClientGraphQLServer.ts index dd8adfd039f..d7e9d017ed2 100644 --- a/packages/client/modules/demo/ClientGraphQLServer.ts +++ b/packages/client/modules/demo/ClientGraphQLServer.ts @@ -14,7 +14,6 @@ import NewMeetingPhase from '../../../server/database/types/GenericMeetingPhase' import NewMeetingStage from '../../../server/database/types/GenericMeetingStage' import GoogleAnalyzedEntity from '../../../server/database/types/GoogleAnalyzedEntity' import ReflectPhase from '../../../server/database/types/ReflectPhase' -import Reflection from '../../../server/database/types/Reflection' import ITask from '../../../server/database/types/Task' import { ExternalLinks, @@ -50,7 +49,7 @@ import initDB, { demoViewerId } from './initDB' -export type DemoReflection = Omit & { +export type DemoReflection = { __typename: string createdAt: string | Date dragContext: any @@ -66,6 +65,13 @@ export type DemoReflection = Omit = Pick> export type Subtract = Omit @@ -108,3 +109,6 @@ declare global { findLastIndex(predicate: (value: T, index: number, obj: T[]) => unknown, thisArg?: any): number } } + +export type ExtractTypeFromQueryBuilderSelect any> = + ReturnType extends SelectQueryBuilder ? X : never diff --git a/packages/client/utils/smartGroup/groupReflections.ts b/packages/client/utils/smartGroup/groupReflections.ts index 0cfd9c0316a..ea20495b808 100644 --- a/packages/client/utils/smartGroup/groupReflections.ts +++ b/packages/client/utils/smartGroup/groupReflections.ts @@ -1,4 +1,3 @@ -import Reflection from '~/../server/database/types/Reflection' import computeDistanceMatrix from './computeDistanceMatrix' import getAllLemmasFromReflections from './getAllLemmasFromReflections' import getGroupMatrix from './getGroupMatrix' @@ -28,7 +27,7 @@ export type GroupingOptions = { maxReductionPercent?: number } -const groupReflections = ( +const groupReflections = ( reflections: T[], groupingOptions: GroupingOptions ) => { diff --git a/packages/server/database/rethinkDriver.ts b/packages/server/database/rethinkDriver.ts index 40621c4db4c..0470ed0a0f7 100644 --- a/packages/server/database/rethinkDriver.ts +++ b/packages/server/database/rethinkDriver.ts @@ -29,18 +29,14 @@ import NotificationTeamInvitation from './types/NotificationTeamInvitation' import OrganizationUser from './types/OrganizationUser' import PasswordResetRequest from './types/PasswordResetRequest' import PushInvitation from './types/PushInvitation' -import Reflection from './types/Reflection' import RetrospectivePrompt from './types/RetrospectivePrompt' -import SAML from './types/SAML' import SuggestedActionCreateNewTeam from './types/SuggestedActionCreateNewTeam' import SuggestedActionInviteYourTeam from './types/SuggestedActionInviteYourTeam' import SuggestedActionTryTheDemo from './types/SuggestedActionTryTheDemo' import Task from './types/Task' -import Team from './types/Team' import TemplateDimension from './types/TemplateDimension' import TemplateScale from './types/TemplateScale' import TimelineEvent from './types/TimelineEvent' -import User from './types/User' export type RethinkSchema = { AgendaItem: { @@ -142,14 +138,6 @@ export type RethinkSchema = { type: MeetingTemplate index: 'teamId' | 'orgId' } - RetroReflection: { - type: Reflection - index: 'meetingId' | 'reflectionGroupId' - } - SAML: { - type: SAML - index: 'domains' | 'orgId' - } ScheduledJob: { type: ScheduledJobUnion index: 'runAt' | 'type' @@ -183,10 +171,6 @@ export type RethinkSchema = { type: any index: 'taskIdUpdatedAt' | 'teamMemberId' } - Team: { - type: Team - index: 'orgId' - } TeamInvitation: { type: TeamInvitation index: 'email' | 'teamId' | 'token' @@ -207,10 +191,6 @@ export type RethinkSchema = { type: TimelineEvent index: 'userIdCreatedAt' | 'meetingId' } - User: { - type: User - index: 'email' - } } export type DBType = { diff --git a/packages/server/database/types/GoogleAnalyzedEntity.ts b/packages/server/database/types/GoogleAnalyzedEntity.ts index b148d69967e..e66ce25f7c3 100644 --- a/packages/server/database/types/GoogleAnalyzedEntity.ts +++ b/packages/server/database/types/GoogleAnalyzedEntity.ts @@ -1,3 +1,5 @@ +import {sql} from 'kysely' + interface Input { lemma?: string name: string @@ -15,3 +17,8 @@ export default class GoogleAnalyzedEntity { this.salience = salience } } + +export const toGoogleAnalyzedEntityPG = (entities: GoogleAnalyzedEntity[]) => + sql< + string[] + >`(select coalesce(array_agg((name, salience, lemma)::"GoogleAnalyzedEntity"), '{}') from json_populate_recordset(null::"GoogleAnalyzedEntity", ${JSON.stringify(entities)}))` diff --git a/packages/server/database/types/Reactable.ts b/packages/server/database/types/Reactable.ts index e4fc841e8c1..2cfd490359c 100644 --- a/packages/server/database/types/Reactable.ts +++ b/packages/server/database/types/Reactable.ts @@ -1,6 +1,6 @@ +import {RetroReflectionSource} from '../../graphql/public/types/RetroReflection' import {TeamPromptResponse} from '../../postgres/queries/getTeamPromptResponsesByIds' import Comment from './Comment' -import Reflection from './Reflection' export type ReactableEnum = 'COMMENT' | 'REFLECTION' | 'RESPONSE' -export type Reactable = Reflection | Comment | TeamPromptResponse +export type Reactable = RetroReflectionSource | Comment | TeamPromptResponse diff --git a/packages/server/database/types/Reflection.ts b/packages/server/database/types/Reflection.ts deleted file mode 100644 index 93543c8b7e2..00000000000 --- a/packages/server/database/types/Reflection.ts +++ /dev/null @@ -1,84 +0,0 @@ -import {sql} from 'kysely' -import extractTextFromDraftString from 'parabol-client/utils/draftjs/extractTextFromDraftString' -import generateUID from '../../generateUID' -import GoogleAnalyzedEntity from './GoogleAnalyzedEntity' -import Reactji from './Reactji' - -export const toGoogleAnalyzedEntityPG = (entities: GoogleAnalyzedEntity[]) => - sql< - string[] - >`(select coalesce(array_agg((name, salience, lemma)::"GoogleAnalyzedEntity"), '{}') from json_populate_recordset(null::"GoogleAnalyzedEntity", ${JSON.stringify(entities)}))` - -export interface ReflectionInput { - id?: string - createdAt?: Date - creatorId: string - content: string - plaintextContent?: string // the plaintext version of content - entities: GoogleAnalyzedEntity[] - sentimentScore?: number - meetingId: string - reactjis?: Reactji[] - reflectionGroupId?: string - promptId: string - sortOrder?: number - updatedAt?: Date -} - -export default class Reflection { - id: string - createdAt: Date - // userId of the creator - creatorId: string | null - content: string - plaintextContent: string - entities: GoogleAnalyzedEntity[] - sentimentScore?: number - isActive: boolean - meetingId: string - reactjis: Reactji[] - reflectionGroupId: string - promptId: string - sortOrder: number - updatedAt: Date - constructor(input: ReflectionInput) { - const { - content, - plaintextContent, - createdAt, - creatorId, - entities, - sentimentScore, - id, - meetingId, - reactjis, - reflectionGroupId, - promptId, - sortOrder, - updatedAt - } = input - const now = new Date() - this.id = id || generateUID() - this.createdAt = createdAt || now - this.creatorId = creatorId - this.content = content - this.plaintextContent = plaintextContent || extractTextFromDraftString(content) - this.entities = entities - this.sentimentScore = sentimentScore - this.isActive = true - this.meetingId = meetingId - this.reactjis = reactjis || [] - this.reflectionGroupId = reflectionGroupId || generateUID() - this.promptId = promptId - this.sortOrder = sortOrder || 0 - this.updatedAt = updatedAt || now - } - toPG() { - return { - ...this, - reactjis: this.reactjis.map((r) => `(${r.id},${r.userId})`), - // this is complex because we have to account for escape chars. it's safest to pass in a JSON object & let PG do the conversion for us - entities: toGoogleAnalyzedEntityPG(this.entities) - } - } -} diff --git a/packages/server/dataloader/foreignKeyLoaderMakers.ts b/packages/server/dataloader/foreignKeyLoaderMakers.ts index 8807ba06f17..dc57123c89a 100644 --- a/packages/server/dataloader/foreignKeyLoaderMakers.ts +++ b/packages/server/dataloader/foreignKeyLoaderMakers.ts @@ -1,6 +1,7 @@ import getKysely from '../postgres/getKysely' import getTeamsByOrgIds from '../postgres/queries/getTeamsByOrgIds' import {foreignKeyLoaderMaker} from './foreignKeyLoaderMaker' +import {selectRetroReflections} from './primaryKeyLoaderMakers' export const teamsByOrgIds = foreignKeyLoaderMaker('teams', 'orgId', (orgIds) => getTeamsByOrgIds(orgIds, {isArchived: false}) @@ -37,3 +38,25 @@ export const retroReflectionGroupsByMeetingId = foreignKeyLoaderMaker( .execute() } ) + +export const retroReflectionsByMeetingId = foreignKeyLoaderMaker( + 'retroReflections', + 'meetingId', + async (meetingIds) => { + return selectRetroReflections() + .where('meetingId', 'in', meetingIds) + .where('isActive', '=', true) + .execute() + } +) + +export const retroReflectionsByGroupId = foreignKeyLoaderMaker( + 'retroReflections', + 'reflectionGroupId', + async (reflectionGroupIds) => { + return selectRetroReflections() + .where('reflectionGroupId', 'in', reflectionGroupIds) + .where('isActive', '=', true) + .execute() + } +) diff --git a/packages/server/dataloader/primaryKeyLoaderMakers.ts b/packages/server/dataloader/primaryKeyLoaderMakers.ts index 87c8adafc0a..a3f096efbda 100644 --- a/packages/server/dataloader/primaryKeyLoaderMakers.ts +++ b/packages/server/dataloader/primaryKeyLoaderMakers.ts @@ -27,3 +27,29 @@ export const embeddingsMetadata = primaryKeyLoaderMaker((ids: readonly number[]) export const retroReflectionGroups = primaryKeyLoaderMaker((ids: readonly string[]) => { return getKysely().selectFrom('RetroReflectionGroup').selectAll().where('id', 'in', ids).execute() }) + +export const selectRetroReflections = () => + getKysely() + .selectFrom('RetroReflection') + .select([ + 'id', + 'content', + 'createdAt', + 'creatorId', + 'isActive', + 'meetingId', + 'plaintextContent', + 'promptId', + 'reflectionGroupId', + 'sentimentScore', + 'sortOrder', + 'updatedAt' + ]) + .select(({fn}) => [ + fn<{lemma: string; salience: number; name: string}[]>('to_json', ['entities']).as('entities'), + fn<{id: string; userId: string}[]>('to_json', ['reactjis']).as('reactjis') + ]) + +export const retroReflections = primaryKeyLoaderMaker((ids: readonly string[]) => { + return selectRetroReflections().where('id', 'in', ids).execute() +}) diff --git a/packages/server/dataloader/rethinkForeignKeyLoaderMakers.ts b/packages/server/dataloader/rethinkForeignKeyLoaderMakers.ts index d301d186beb..24a4abe50d0 100644 --- a/packages/server/dataloader/rethinkForeignKeyLoaderMakers.ts +++ b/packages/server/dataloader/rethinkForeignKeyLoaderMakers.ts @@ -166,34 +166,6 @@ export const scalesByTeamId = new RethinkForeignKeyLoaderMaker( } ) -// Migrating to PG by June 30, 2024 -export const retroReflectionsByMeetingId = new RethinkForeignKeyLoaderMaker( - 'retroReflections', - 'meetingId', - async (meetingIds) => { - const r = await getRethink() - return r - .table('RetroReflection') - .getAll(r.args(meetingIds), {index: 'meetingId'}) - .filter({isActive: true}) - .run() - } -) - -// Migrating to PG by June 30, 2024 -export const retroReflectionsByGroupId = new RethinkForeignKeyLoaderMaker( - 'retroReflections', - 'reflectionGroupId', - async (reflectionGroupIds) => { - const r = await getRethink() - return r - .table('RetroReflection') - .getAll(r.args(reflectionGroupIds), {index: 'reflectionGroupId'}) - .filter({isActive: true}) - .run() - } -) - export const templateDimensionsByTemplateId = new RethinkForeignKeyLoaderMaker( 'templateDimensions', 'templateId', diff --git a/packages/server/dataloader/rethinkPrimaryKeyLoaderMakers.ts b/packages/server/dataloader/rethinkPrimaryKeyLoaderMakers.ts index 75497fb3bc0..1a4efd5417a 100644 --- a/packages/server/dataloader/rethinkPrimaryKeyLoaderMakers.ts +++ b/packages/server/dataloader/rethinkPrimaryKeyLoaderMakers.ts @@ -16,8 +16,6 @@ export const notifications = new RethinkPrimaryKeyLoaderMaker('Notification') export const organizations = new RethinkPrimaryKeyLoaderMaker('Organization') export const organizationUsers = new RethinkPrimaryKeyLoaderMaker('OrganizationUser') export const templateScales = new RethinkPrimaryKeyLoaderMaker('TemplateScale') -// Migrating to PG by June 30, 2024 -export const retroReflections = new RethinkPrimaryKeyLoaderMaker('RetroReflection') export const slackAuths = new RethinkPrimaryKeyLoaderMaker('SlackAuth') export const slackNotifications = new RethinkPrimaryKeyLoaderMaker('SlackNotification') export const suggestedActions = new RethinkPrimaryKeyLoaderMaker('SuggestedAction') diff --git a/packages/server/graphql/mutations/createReflection.ts b/packages/server/graphql/mutations/createReflection.ts index 61adf5d161d..577eef6b48a 100644 --- a/packages/server/graphql/mutations/createReflection.ts +++ b/packages/server/graphql/mutations/createReflection.ts @@ -6,7 +6,7 @@ import getGroupSmartTitle from 'parabol-client/utils/smartGroup/getGroupSmartTit import unlockAllStagesForPhase from 'parabol-client/utils/unlockAllStagesForPhase' import normalizeRawDraftJS from 'parabol-client/validation/normalizeRawDraftJS' import getRethink from '../../database/rethinkDriver' -import Reflection from '../../database/types/Reflection' +import {toGoogleAnalyzedEntityPG} from '../../database/types/GoogleAnalyzedEntity' import ReflectionGroup from '../../database/types/ReflectionGroup' import generateUID from '../../generateUID' import getKysely from '../../postgres/getKysely' @@ -37,7 +37,6 @@ export default { const r = await getRethink() const pg = getKysely() const operationId = dataLoader.share() - const now = new Date() const subOptions = {operationId, mutatorId} const {content, sortOrder, meetingId, promptId} = input // AUTH @@ -78,17 +77,18 @@ export default { ]) const reflectionGroupId = generateUID() - const reflection = new Reflection({ + const reflection = { + id: generateUID(), creatorId: viewerId, content: normalizedContent, plaintextContent, entities, sentimentScore, + isActive: true, meetingId, promptId, - reflectionGroupId, - updatedAt: now - }) + reflectionGroupId + } const smartTitle = getGroupSmartTitle([reflection]) const reflectionGroup = new ReflectionGroup({ @@ -100,14 +100,11 @@ export default { sortOrder }) - await Promise.all([ - pg - .with('Group', (qc) => qc.insertInto('RetroReflectionGroup').values(reflectionGroup)) - .insertInto('RetroReflection') - .values(reflection.toPG()) - .execute(), - r.table('RetroReflection').insert(reflection).run() - ]) + await pg + .with('Group', (qc) => qc.insertInto('RetroReflectionGroup').values(reflectionGroup)) + .insertInto('RetroReflection') + .values({...reflection, entities: toGoogleAnalyzedEntityPG(entities)}) + .execute() const groupPhase = phases.find((phase) => phase.phaseType === 'group')! const {stages} = groupPhase diff --git a/packages/server/graphql/mutations/helpers/generateGroups.ts b/packages/server/graphql/mutations/helpers/generateGroups.ts index 77d7ab7e1f4..a7e80d060a3 100644 --- a/packages/server/graphql/mutations/helpers/generateGroups.ts +++ b/packages/server/graphql/mutations/helpers/generateGroups.ts @@ -1,15 +1,15 @@ import {SubscriptionChannel} from '../../../../client/types/constEnums' import getRethink from '../../../database/rethinkDriver' import {AutogroupReflectionGroupType} from '../../../database/types/MeetingRetrospective' -import Reflection from '../../../database/types/Reflection' import {Logger} from '../../../utils/Logger' import OpenAIServerManager from '../../../utils/OpenAIServerManager' import {analytics} from '../../../utils/analytics/analytics' import publish from '../../../utils/publish' import {DataLoaderWorker} from '../../graphql' +import {RetroReflectionSource} from '../../public/types/RetroReflection' const generateGroups = async ( - reflections: Reflection[], + reflections: RetroReflectionSource[], teamId: string, dataLoader: DataLoaderWorker ) => { diff --git a/packages/server/graphql/mutations/helpers/removeEmptyReflections.ts b/packages/server/graphql/mutations/helpers/removeEmptyReflections.ts index 0532e1c4054..e11080a9bb6 100644 --- a/packages/server/graphql/mutations/helpers/removeEmptyReflections.ts +++ b/packages/server/graphql/mutations/helpers/removeEmptyReflections.ts @@ -1,11 +1,9 @@ import extractTextFromDraftString from 'parabol-client/utils/draftjs/extractTextFromDraftString' -import getRethink from '../../../database/rethinkDriver' import Meeting from '../../../database/types/Meeting' import {DataLoaderInstance} from '../../../dataloader/RootDataLoader' import getKysely from '../../../postgres/getKysely' const removeEmptyReflections = async (meeting: Meeting, dataLoader: DataLoaderInstance) => { - const r = await getRethink() const pg = getKysely() const {id: meetingId} = meeting const reflections = await dataLoader.get('retroReflectionsByMeetingId').load(meetingId) @@ -22,13 +20,6 @@ const removeEmptyReflections = async (meeting: Meeting, dataLoader: DataLoaderIn }) if (emptyReflectionGroupIds.length > 0) { await Promise.all([ - r - .table('RetroReflection') - .getAll(r.args(emptyReflectionIds), {index: 'id'}) - .update({ - isActive: false - }) - .run(), pg .updateTable('RetroReflection') .set({isActive: false}) diff --git a/packages/server/graphql/mutations/helpers/updateReflectionLocation/addReflectionToGroup.ts b/packages/server/graphql/mutations/helpers/updateReflectionLocation/addReflectionToGroup.ts index e858cd01f4a..bde8d49d943 100644 --- a/packages/server/graphql/mutations/helpers/updateReflectionLocation/addReflectionToGroup.ts +++ b/packages/server/graphql/mutations/helpers/updateReflectionLocation/addReflectionToGroup.ts @@ -1,6 +1,5 @@ import dndNoise from 'parabol-client/utils/dndNoise' import getGroupSmartTitle from 'parabol-client/utils/smartGroup/getGroupSmartTitle' -import getRethink from '../../../../database/rethinkDriver' import getKysely from '../../../../postgres/getKysely' import {GQLContext} from './../../../graphql' import updateSmartGroupTitle from './updateSmartGroupTitle' @@ -11,7 +10,6 @@ const addReflectionToGroup = async ( {dataLoader}: GQLContext, smartTitle?: string ) => { - const r = await getRethink() const pg = getKysely() const now = new Date() const reflection = await dataLoader.get('retroReflections').load(reflectionId) @@ -39,25 +37,14 @@ const addReflectionToGroup = async ( // RESOLUTION const sortOrder = maxSortOrder + 1 + dndNoise() - await Promise.all([ - pg - .updateTable('RetroReflection') - .set({ - sortOrder, - reflectionGroupId - }) - .where('id', '=', reflectionId) - .execute(), - r - .table('RetroReflection') - .get(reflectionId) - .update({ - sortOrder, - reflectionGroupId, - updatedAt: now - }) - .run() - ]) + await pg + .updateTable('RetroReflection') + .set({ + sortOrder, + reflectionGroupId + }) + .where('id', '=', reflectionId) + .execute() // mutate the dataLoader cache reflection.sortOrder = sortOrder reflection.reflectionGroupId = reflectionGroupId diff --git a/packages/server/graphql/mutations/helpers/updateReflectionLocation/removeReflectionFromGroup.ts b/packages/server/graphql/mutations/helpers/updateReflectionLocation/removeReflectionFromGroup.ts index b54764c94f7..389ea399d3d 100644 --- a/packages/server/graphql/mutations/helpers/updateReflectionLocation/removeReflectionFromGroup.ts +++ b/packages/server/graphql/mutations/helpers/updateReflectionLocation/removeReflectionFromGroup.ts @@ -10,7 +10,6 @@ import updateSmartGroupTitle from './updateSmartGroupTitle' const removeReflectionFromGroup = async (reflectionId: string, {dataLoader}: GQLContext) => { const r = await getRethink() const pg = getKysely() - const now = new Date() const reflection = await dataLoader.get('retroReflections').load(reflectionId) if (!reflection) throw new Error('Reflection not found') const {reflectionGroupId: oldReflectionGroupId, meetingId, promptId} = reflection @@ -54,15 +53,6 @@ const removeReflectionFromGroup = async (reflectionId: string, {dataLoader}: GQL }) .where('id', '=', reflectionId) .execute(), - r - .table('RetroReflection') - .get(reflectionId) - .update({ - sortOrder: 0, - reflectionGroupId, - updatedAt: now - }) - .run(), r.table('NewMeeting').get(meetingId).update({nextAutoGroupThreshold: null}).run() ]) // mutates the dataloader response diff --git a/packages/server/graphql/mutations/removeReflection.ts b/packages/server/graphql/mutations/removeReflection.ts index c6fd1be8ab2..83460fccc5d 100644 --- a/packages/server/graphql/mutations/removeReflection.ts +++ b/packages/server/graphql/mutations/removeReflection.ts @@ -27,7 +27,6 @@ export default { const r = await getRethink() const pg = getKysely() const operationId = dataLoader.share() - const now = new Date() const subOptions = {operationId, mutatorId} // AUTH @@ -52,21 +51,11 @@ export default { } // RESOLUTION - await Promise.all([ - pg - .updateTable('RetroReflection') - .set({isActive: false}) - .where('id', '=', reflectionId) - .execute(), - r - .table('RetroReflection') - .get(reflectionId) - .update({ - isActive: false, - updatedAt: now - }) - .run() - ]) + await pg + .updateTable('RetroReflection') + .set({isActive: false}) + .where('id', '=', reflectionId) + .execute() await removeEmptyReflectionGroup(reflectionGroupId, reflectionGroupId, dataLoader) const reflections = await dataLoader.get('retroReflectionsByMeetingId').load(meetingId) let unlockedStageIds diff --git a/packages/server/graphql/mutations/updateReflectionContent.ts b/packages/server/graphql/mutations/updateReflectionContent.ts index fb32bbd283e..c716f776884 100644 --- a/packages/server/graphql/mutations/updateReflectionContent.ts +++ b/packages/server/graphql/mutations/updateReflectionContent.ts @@ -5,8 +5,7 @@ import isPhaseComplete from 'parabol-client/utils/meetings/isPhaseComplete' import getGroupSmartTitle from 'parabol-client/utils/smartGroup/getGroupSmartTitle' import normalizeRawDraftJS from 'parabol-client/validation/normalizeRawDraftJS' import stringSimilarity from 'string-similarity' -import getRethink from '../../database/rethinkDriver' -import {toGoogleAnalyzedEntityPG} from '../../database/types/Reflection' +import {toGoogleAnalyzedEntityPG} from '../../database/types/GoogleAnalyzedEntity' import getKysely from '../../postgres/getKysely' import {getUserId, isTeamMember} from '../../utils/authorization' import publish from '../../utils/publish' @@ -35,10 +34,8 @@ export default { {reflectionId, content}: {reflectionId: string; content: string}, {authToken, dataLoader, socketId: mutatorId}: GQLContext ) { - const r = await getRethink() const pg = getKysely() const operationId = dataLoader.share() - const now = new Date() const subOptions = {operationId, mutatorId} // AUTH @@ -87,29 +84,17 @@ export default { ? await getReflectionSentimentScore(question, plaintextContent) : reflection.sentimentScore : undefined - await Promise.all([ - pg - .updateTable('RetroReflection') - .set({ - content: normalizedContent, - entities: toGoogleAnalyzedEntityPG(entities), - sentimentScore, - plaintextContent - }) - .where('id', '=', reflectionId) - .execute(), - r - .table('RetroReflection') - .get(reflectionId) - .update({ - content: normalizedContent, - entities, - sentimentScore, - plaintextContent, - updatedAt: now - }) - .run() - ]) + await pg + .updateTable('RetroReflection') + .set({ + content: normalizedContent, + entities: toGoogleAnalyzedEntityPG(entities), + sentimentScore, + plaintextContent + }) + .where('id', '=', reflectionId) + .execute() + const reflectionsInGroup = await dataLoader .get('retroReflectionsByGroupId') .load(reflectionGroupId) diff --git a/packages/server/graphql/private/mutations/backupOrganization.ts b/packages/server/graphql/private/mutations/backupOrganization.ts index b4db1ae9ef5..4a2687025e0 100644 --- a/packages/server/graphql/private/mutations/backupOrganization.ts +++ b/packages/server/graphql/private/mutations/backupOrganization.ts @@ -290,17 +290,6 @@ const backupOrganization: MutationResolvers['backupOrganization'] = async (_sour }) }).run() - // remove teamIds that are not part of the desired orgIds - await r - .db('orgBackup') - .table('User') - .update((row: RValue) => ({ - tms: row('tms') - .innerJoin(r(teamIds), (a: RValue, b: RValue) => a.eq(b)) - .zip() - })) - .run() - return `Success! 'orgBackup' contains all the records for ${orgIds.join(', ')}` } diff --git a/packages/server/graphql/private/mutations/checkRethinkPgEquality.ts b/packages/server/graphql/private/mutations/checkRethinkPgEquality.ts index 60a38343f61..954411c9f50 100644 --- a/packages/server/graphql/private/mutations/checkRethinkPgEquality.ts +++ b/packages/server/graphql/private/mutations/checkRethinkPgEquality.ts @@ -40,7 +40,7 @@ const checkRethinkPgEquality: MutationResolvers['checkRethinkPgEquality'] = asyn const rowCountResult = await checkRowCount(tableName) const rethinkQuery = (updatedAt: Date, id: string | number) => { return r - .table('RetroReflection') + .table('RetroReflection' as any) .between([updatedAt, id], [r.maxval, r.maxval], { index: 'updatedAtId', leftBound: 'open', diff --git a/packages/server/graphql/private/mutations/hardDeleteUser.ts b/packages/server/graphql/private/mutations/hardDeleteUser.ts index 403ccdbf4a2..da6a190260c 100644 --- a/packages/server/graphql/private/mutations/hardDeleteUser.ts +++ b/packages/server/graphql/private/mutations/hardDeleteUser.ts @@ -49,73 +49,54 @@ const hardDeleteUser: MutationResolvers['hardDeleteUser'] = async ( const teamIds = teamMemberIds.map((id) => TeamMemberId.split(id).teamId) // need to fetch these upfront - const [ - onePersonMeetingIds, - retroReflectionIds, - swapFacilitatorUpdates, - swapCreatedByUserUpdates, - discussions - ] = await Promise.all([ - ( + const [onePersonMeetingIds, swapFacilitatorUpdates, swapCreatedByUserUpdates, discussions] = + await Promise.all([ + ( + r + .table('MeetingMember') + .getAll(r.args(meetingIds), {index: 'meetingId'}) + .group('meetingId') as any + ) + .count() + .ungroup() + .filter((row: RValue) => row('reduction').le(1)) + .map((row: RValue) => row('group')) + .coerceTo('array') + .run(), r - .table('MeetingMember') - .getAll(r.args(meetingIds), {index: 'meetingId'}) - .group('meetingId') as any - ) - .count() - .ungroup() - .filter((row: RValue) => row('reduction').le(1)) - .map((row: RValue) => row('group')) - .coerceTo('array') - .run(), - // Migrating to PG by June 30, 2024 - ( + .table('NewMeeting') + .getAll(r.args(teamIds), {index: 'teamId'}) + .filter((row: RValue) => row('facilitatorUserId').eq(userIdToDelete)) + .merge((meeting: RValue) => ({ + otherTeamMember: r + .table('TeamMember') + .getAll(meeting('teamId'), {index: 'teamId'}) + .filter((row: RValue) => row('userId').ne(userIdToDelete)) + .nth(0) + .getField('userId') + .default(null) + })) + .filter(r.row.hasFields('otherTeamMember')) + .pluck('id', 'otherTeamMember') + .run(), r .table('NewMeeting') .getAll(r.args(teamIds), {index: 'teamId'}) - .filter((row: RValue) => row('meetingType').eq('retro')) - .eqJoin('id', r.table('RetroReflection'), {index: 'meetingId'}) - .zip() as any - ) - .filter((row: RValue) => row('creatorId').eq(userIdToDelete)) - .getField('id') - .coerceTo('array') - .distinct() - .run(), - r - .table('NewMeeting') - .getAll(r.args(teamIds), {index: 'teamId'}) - .filter((row: RValue) => row('facilitatorUserId').eq(userIdToDelete)) - .merge((meeting: RValue) => ({ - otherTeamMember: r - .table('TeamMember') - .getAll(meeting('teamId'), {index: 'teamId'}) - .filter((row: RValue) => row('userId').ne(userIdToDelete)) - .nth(0) - .getField('userId') - .default(null) - })) - .filter(r.row.hasFields('otherTeamMember')) - .pluck('id', 'otherTeamMember') - .run(), - r - .table('NewMeeting') - .getAll(r.args(teamIds), {index: 'teamId'}) - .filter((row: RValue) => row('createdBy').eq(userIdToDelete)) - .merge((meeting: RValue) => ({ - otherTeamMember: r - .table('TeamMember') - .getAll(meeting('teamId'), {index: 'teamId'}) - .filter((row: RValue) => row('userId').ne(userIdToDelete)) - .nth(0) - .getField('userId') - .default(null) - })) - .filter(r.row.hasFields('otherTeamMember')) - .pluck('id', 'otherTeamMember') - .run(), - pg.query(`SELECT "id" FROM "Discussion" WHERE "teamId" = ANY ($1);`, [teamIds]) - ]) + .filter((row: RValue) => row('createdBy').eq(userIdToDelete)) + .merge((meeting: RValue) => ({ + otherTeamMember: r + .table('TeamMember') + .getAll(meeting('teamId'), {index: 'teamId'}) + .filter((row: RValue) => row('userId').ne(userIdToDelete)) + .nth(0) + .getField('userId') + .default(null) + })) + .filter(r.row.hasFields('otherTeamMember')) + .pluck('id', 'otherTeamMember') + .run(), + pg.query(`SELECT "id" FROM "Discussion" WHERE "teamId" = ANY ($1);`, [teamIds]) + ]) const teamDiscussionIds = discussions.rows.map(({id}) => id) // soft delete first for side effects @@ -148,11 +129,6 @@ const hardDeleteUser: MutationResolvers['hardDeleteUser'] = async ( .filter((row: RValue) => r(teamMemberIds).contains(row('teamMemberId'))) .delete(), pushInvitation: r.table('PushInvitation').getAll(userIdToDelete, {index: 'userId'}).delete(), - // Migrating to PG by June 30, 2024 - retroReflection: r - .table('RetroReflection') - .getAll(r.args(retroReflectionIds), {index: 'id'}) - .update({creatorId: null}), slackNotification: r .table('SlackNotification') .getAll(userIdToDelete, {index: 'userId'}) diff --git a/packages/server/graphql/public/mutations/addReactjiToReactable.ts b/packages/server/graphql/public/mutations/addReactjiToReactable.ts index a35d00b5e7d..7255c0df9d2 100644 --- a/packages/server/graphql/public/mutations/addReactjiToReactable.ts +++ b/packages/server/graphql/public/mutations/addReactjiToReactable.ts @@ -6,7 +6,6 @@ import {ValueOf} from '../../../../client/types/generics' import getRethink from '../../../database/rethinkDriver' import {RDatum} from '../../../database/stricterR' import Comment from '../../../database/types/Comment' -import Reflection from '../../../database/types/Reflection' import getKysely from '../../../postgres/getKysely' import {analytics} from '../../../utils/analytics/analytics' import {getUserId} from '../../../utils/authorization' @@ -116,12 +115,12 @@ const addReactjiToReactable: MutationResolvers['addReactjiToReactable'] = async } const updateRethink = async (rethinkDbTable: ValueOf) => { - if (rethinkDbTable === 'TeamPromptResponse') return + if (rethinkDbTable === 'TeamPromptResponse' || rethinkDbTable === 'RetroReflection') return if (isRemove) { await r .table(rethinkDbTable) .get(dbId) - .update((row: RDatum) => ({ + .update((row: RDatum) => ({ reactjis: row('reactjis').difference([subDoc]), updatedAt: now })) @@ -130,7 +129,7 @@ const addReactjiToReactable: MutationResolvers['addReactjiToReactable'] = async await r .table(rethinkDbTable) .get(dbId) - .update((row: RDatum) => ({ + .update((row: RDatum) => ({ reactjis: r.branch( row('reactjis').contains(subDoc), row('reactjis'), diff --git a/packages/server/graphql/public/typeDefs/Reactji.graphql b/packages/server/graphql/public/typeDefs/Reactji.graphql new file mode 100644 index 00000000000..5c8dd7afeb1 --- /dev/null +++ b/packages/server/graphql/public/typeDefs/Reactji.graphql @@ -0,0 +1,24 @@ +""" +An aggregate of reactji metadata +""" +type Reactji { + """ + composite of entity:reactjiId + """ + id: ID! + + """ + The number of users who have added this reactji + """ + count: Int! + + """ + The users who added a reactji + """ + users: [User!]! + + """ + true if the viewer is included in the count, else false + """ + isViewerReactji: Boolean! +} diff --git a/packages/server/graphql/public/typeDefs/RetroReflection.graphql b/packages/server/graphql/public/typeDefs/RetroReflection.graphql new file mode 100644 index 00000000000..1e0cda202f1 --- /dev/null +++ b/packages/server/graphql/public/typeDefs/RetroReflection.graphql @@ -0,0 +1,105 @@ +""" +A reflection created during the reflect phase of a retrospective +""" +type RetroReflection implements Reactable { + """ + shortid + """ + id: ID! + + """ + All the reactjis for the given reflection + """ + reactjis: [Reactji!]! + + """ + The ID of the group that the autogrouper assigned the reflection. Error rate = Sum(autoId != Id) / autoId.count() + """ + autoReflectionGroupId: ID + + """ + The timestamp the meeting was created + """ + createdAt: DateTime + + """ + The userId that created the reflection (or unique Id if not a team member) + """ + creatorId: ID + + """ + an array of all the socketIds that are currently editing the reflection + """ + editorIds: [ID!]! + + """ + True if the reflection was not removed, else false + """ + isActive: Boolean! + + """ + true if the viewer (userId) is the creator of the retro reflection, else false + """ + isViewerCreator: Boolean! + + """ + The stringified draft-js content + """ + content: String! + + """ + The entities (i.e. nouns) parsed from the content and their respective salience + """ + entities: [GoogleAnalyzedEntity!]! + + """ + The foreign key to link a reflection to its meeting + """ + meetingId: ID! + + """ + The retrospective meeting this reflection was created in + """ + meeting: RetrospectiveMeeting! + + """ + The plaintext version of content + """ + plaintextContent: String! + + """ + The foreign key to link a reflection to its prompt. Immutable. For sorting, use prompt on the group. + """ + promptId: ID! + prompt: ReflectPrompt! + + """ + The foreign key to link a reflection to its group + """ + reflectionGroupId: ID! + + """ + The group the reflection belongs to, if any + """ + retroReflectionGroup: RetroReflectionGroup + + """ + The sort order of the reflection in the group (increments starting from 0) + """ + sortOrder: Float! + + """ + The team that is running the meeting that contains this reflection + """ + team: Team! + + """ + The timestamp the meeting was updated. Used to determine how long it took to write a reflection + """ + updatedAt: DateTime + + """ + The user that created the reflection, only visible if anonymity is disabled + """ + creator: User +} diff --git a/packages/server/graphql/public/typeDefs/_legacy.graphql b/packages/server/graphql/public/typeDefs/_legacy.graphql index 8b6eb1bc78b..4b4a247f5b1 100644 --- a/packages/server/graphql/public/typeDefs/_legacy.graphql +++ b/packages/server/graphql/public/typeDefs/_legacy.graphql @@ -2862,107 +2862,6 @@ type RetroReflectionGroup { viewerVoteCount: Int } -""" -A reflection created during the reflect phase of a retrospective -""" -type RetroReflection implements Reactable { - """ - shortid - """ - id: ID! - - """ - All the reactjis for the given reflection - """ - reactjis: [Reactji!]! - - """ - The ID of the group that the autogrouper assigned the reflection. Error rate = Sum(autoId != Id) / autoId.count() - """ - autoReflectionGroupId: ID - - """ - The timestamp the meeting was created - """ - createdAt: DateTime - - """ - The userId that created the reflection (or unique Id if not a team member) - """ - creatorId: ID - - """ - an array of all the socketIds that are currently editing the reflection - """ - editorIds: [ID!]! - - """ - True if the reflection was not removed, else false - """ - isActive: Boolean! - - """ - true if the viewer (userId) is the creator of the retro reflection, else false - """ - isViewerCreator: Boolean! - - """ - The stringified draft-js content - """ - content: String! - - """ - The entities (i.e. nouns) parsed from the content and their respective salience - """ - entities: [GoogleAnalyzedEntity!]! - - """ - The foreign key to link a reflection to its meeting - """ - meetingId: ID! - - """ - The retrospective meeting this reflection was created in - """ - meeting: RetrospectiveMeeting! - - """ - The plaintext version of content - """ - plaintextContent: String! - - """ - The foreign key to link a reflection to its prompt. Immutable. For sorting, use prompt on the group. - """ - promptId: ID! - prompt: ReflectPrompt! - - """ - The foreign key to link a reflection to its group - """ - reflectionGroupId: ID! - - """ - The group the reflection belongs to, if any - """ - retroReflectionGroup: RetroReflectionGroup - - """ - The sort order of the reflection in the group (increments starting from 0) - """ - sortOrder: Float! - - """ - The team that is running the meeting that contains this reflection - """ - team: Team! - - """ - The timestamp the meeting was updated. Used to determine how long it took to write a reflection - """ - updatedAt: DateTime -} - """ An item that can have reactjis """ @@ -2978,26 +2877,6 @@ interface Reactable { reactjis: [Reactji!]! } -""" -An aggregate of reactji metadata -""" -type Reactji { - """ - composite of entity:reactjiId - """ - id: ID! - - """ - The number of users who have added this reactji - """ - count: Int! - - """ - true if the viewer is included in the count, else false - """ - isViewerReactji: Boolean! -} - type GoogleAnalyzedEntity { """ The lemma (dictionary entry) of the entity name. Fancy way of saying the singular form of the name, if plural. diff --git a/packages/server/graphql/public/types/NotifyMentioned.ts b/packages/server/graphql/public/types/NotifyMentioned.ts index 6b122ea703b..2272a79690a 100644 --- a/packages/server/graphql/public/types/NotifyMentioned.ts +++ b/packages/server/graphql/public/types/NotifyMentioned.ts @@ -4,11 +4,7 @@ const NotifyMentioned: NotifyMentionedResolvers = { __isTypeOf: ({type}) => type === 'MENTIONED', retroReflection: async ({retroReflectionId}, _args, {dataLoader}) => { if (!retroReflectionId) return null - const retroReflection = dataLoader.get('retroReflections').load(retroReflectionId) - if (!retroReflection) { - return null - } - return retroReflection + return dataLoader.get('retroReflections').loadNonNull(retroReflectionId) }, senderPicture: async ({senderPicture}, _args, {dataLoader}) => { if (!senderPicture) return null diff --git a/packages/server/graphql/public/types/Reactji.ts b/packages/server/graphql/public/types/Reactji.ts new file mode 100644 index 00000000000..d11a21c601a --- /dev/null +++ b/packages/server/graphql/public/types/Reactji.ts @@ -0,0 +1,18 @@ +import isValid from '../../isValid' +import {ReactjiResolvers} from '../resolverTypes' + +export type ReactjiSource = { + id: string + userIds: string[] + count: number + isViewerReactji: boolean +} + +const Reactji: ReactjiResolvers = { + users: async ({userIds}, _args, {dataLoader}) => { + const users = await dataLoader.get('users').loadMany(userIds) + return users.filter(isValid) + } +} + +export default Reactji diff --git a/packages/server/graphql/public/types/RetroReflection.ts b/packages/server/graphql/public/types/RetroReflection.ts new file mode 100644 index 00000000000..116444d224d --- /dev/null +++ b/packages/server/graphql/public/types/RetroReflection.ts @@ -0,0 +1,65 @@ +import {ExtractTypeFromQueryBuilderSelect} from '../../../../client/types/generics' +import MeetingRetrospective from '../../../database/types/MeetingRetrospective' +import {selectRetroReflections} from '../../../dataloader/primaryKeyLoaderMakers' +import {getUserId, isSuperUser} from '../../../utils/authorization' +import getGroupedReactjis from '../../../utils/getGroupedReactjis' +import {RetroReflectionResolvers} from '../resolverTypes' + +export interface RetroReflectionSource + extends ExtractTypeFromQueryBuilderSelect {} + +const RetroReflection: RetroReflectionResolvers = { + creatorId: async ({creatorId, meetingId}, _args, {authToken, dataLoader}) => { + const meeting = await dataLoader.get('newMeetings').load(meetingId) + const {meetingType} = meeting + if (!isSuperUser(authToken) && (meetingType !== 'retrospective' || !meeting.disableAnonymity)) { + return null + } + return creatorId + }, + + creator: async ({creatorId, meetingId}, _args, {dataLoader}) => { + const meeting = await dataLoader.get('newMeetings').load(meetingId) + const {meetingType} = meeting + + // let's not allow super users to grap this in case the UI does not check `disableAnonymity` in which case + // reflection authors would be always visible for them + if (meetingType !== 'retrospective' || !meeting.disableAnonymity || !creatorId) { + return null + } + return dataLoader.get('users').loadNonNull(creatorId) + }, + + editorIds: () => [], + isActive: ({isActive}) => !!isActive, + + isViewerCreator: ({creatorId}, _args, {authToken}) => { + const viewerId = getUserId(authToken) + return viewerId === creatorId + }, + + meeting: async ({meetingId}, _args, {dataLoader}) => { + const meeting = await dataLoader.get('newMeetings').load(meetingId) + return meeting as MeetingRetrospective + }, + + prompt: ({promptId}, _args, {dataLoader}) => { + return dataLoader.get('reflectPrompts').load(promptId) + }, + + reactjis: ({reactjis, id}, _args, {authToken}) => { + const viewerId = getUserId(authToken) + return getGroupedReactjis(reactjis, viewerId, id) + }, + + retroReflectionGroup: async ({reflectionGroupId}, _args, {dataLoader}) => { + return dataLoader.get('retroReflectionGroups').loadNonNull(reflectionGroupId) + }, + + team: async ({meetingId}, _args, {dataLoader}) => { + const meeting = await dataLoader.get('newMeetings').load(meetingId) + return dataLoader.get('teams').loadNonNull(meeting.teamId) + } +} + +export default RetroReflection diff --git a/packages/server/graphql/resolvers.ts b/packages/server/graphql/resolvers.ts index 3be69607f10..b292612f10a 100644 --- a/packages/server/graphql/resolvers.ts +++ b/packages/server/graphql/resolvers.ts @@ -186,7 +186,7 @@ export const makeResolve = (source: any, _args: any, {dataLoader}: GQLContext) => { const idValue = source[idName] const method = isMany ? 'loadMany' : 'load' - return idValue ? (dataLoader.get(dataLoaderName)[method] as any)(idValue) : source[docName] + return idValue ? (dataLoader as any).get(dataLoaderName)[method](idValue) : source[docName] } export const resolveFilterByTeam = diff --git a/packages/server/graphql/types/Reactji.ts b/packages/server/graphql/types/Reactji.ts index fa7375afdf5..09dc61cfb48 100644 --- a/packages/server/graphql/types/Reactji.ts +++ b/packages/server/graphql/types/Reactji.ts @@ -1,44 +1,8 @@ -import { - GraphQLBoolean, - GraphQLID, - GraphQLInt, - GraphQLList, - GraphQLNonNull, - GraphQLObjectType -} from 'graphql' -import {GQLContext} from '../graphql' +import {GraphQLObjectType} from 'graphql' -export type ReactjiType = { - id: string - userIds: string[] - count: number - isViewerReactji: boolean -} - -const Reactji = new GraphQLObjectType({ +const Reactji = new GraphQLObjectType({ name: 'Reactji', - description: 'An aggregate of reactji metadata', - fields: () => ({ - id: { - type: new GraphQLNonNull(GraphQLID), - description: 'composite of entity:reactjiId' - }, - count: { - type: new GraphQLNonNull(GraphQLInt), - description: 'The number of users who have added this reactji' - }, - users: { - type: new GraphQLNonNull(new GraphQLList(new GraphQLNonNull(require('./User').default))), - description: 'The users who added a reactji', - resolve: ({userIds}, _args: unknown, {dataLoader}) => { - return dataLoader.get('users').loadMany(userIds) - } - }, - isViewerReactji: { - type: new GraphQLNonNull(GraphQLBoolean), - description: 'true if the viewer is included in the count, else false' - } - }) + fields: {} }) export default Reactji diff --git a/packages/server/graphql/types/RetroReflection.ts b/packages/server/graphql/types/RetroReflection.ts index 5c10094686c..6c8dc6f5fce 100644 --- a/packages/server/graphql/types/RetroReflection.ts +++ b/packages/server/graphql/types/RetroReflection.ts @@ -1,169 +1,8 @@ -import { - GraphQLBoolean, - GraphQLFloat, - GraphQLID, - GraphQLList, - GraphQLNonNull, - GraphQLObjectType, - GraphQLString -} from 'graphql' -import Reflection from '../../database/types/Reflection' -import {getUserId, isSuperUser} from '../../utils/authorization' -import {GQLContext} from '../graphql' -import {resolveForSU} from '../resolvers' -import resolveReactjis from '../resolvers/resolveReactjis' -import GoogleAnalyzedEntity from './GoogleAnalyzedEntity' -import GraphQLISO8601Type from './GraphQLISO8601Type' -import Reactable, {reactableFields} from './Reactable' -import ReflectPrompt from './ReflectPrompt' -import RetroReflectionGroup from './RetroReflectionGroup' -import RetrospectiveMeeting from './RetrospectiveMeeting' -import Team from './Team' -import User from './User' +import {GraphQLObjectType} from 'graphql' -const RetroReflection = new GraphQLObjectType({ +const RetroReflection = new GraphQLObjectType({ name: 'RetroReflection', - description: 'A reflection created during the reflect phase of a retrospective', - interfaces: () => [Reactable], - fields: () => ({ - ...reactableFields(), - id: { - type: new GraphQLNonNull(GraphQLID), - description: 'shortid' - }, - autoReflectionGroupId: { - type: GraphQLID, - description: - 'The ID of the group that the autogrouper assigned the reflection. Error rate = Sum(autoId != Id) / autoId.count()', - resolve: resolveForSU('autoReflectionGroupId') - }, - createdAt: { - type: GraphQLISO8601Type, - description: 'The timestamp the meeting was created' - }, - creatorId: { - description: 'The userId that created the reflection (or unique Id if not a team member)', - type: GraphQLID, - resolve: async ({creatorId, meetingId}, _args: unknown, {authToken, dataLoader}) => { - const meeting = await dataLoader.get('newMeetings').load(meetingId) - - const {meetingType} = meeting - - if ( - !isSuperUser(authToken) && - (meetingType !== 'retrospective' || !meeting.disableAnonymity) - ) { - return null - } - - return creatorId - } - }, - creator: { - description: 'The user that created the reflection, only visible if anonymity is disabled', - type: User, - resolve: async ({creatorId, meetingId}, _args: unknown, {dataLoader}) => { - const meeting = await dataLoader.get('newMeetings').load(meetingId) - - const {meetingType} = meeting - - // let's not allow super users to grap this in case the UI does not check `disableAnonymity` in which case - // reflection authors would be always visible for them - if (meetingType !== 'retrospective' || !meeting.disableAnonymity || !creatorId) { - return null - } - - return dataLoader.get('users').load(creatorId) - } - }, - editorIds: { - description: 'an array of all the socketIds that are currently editing the reflection', - type: new GraphQLNonNull(new GraphQLList(new GraphQLNonNull(GraphQLID))), - resolve: () => [] - }, - isActive: { - type: new GraphQLNonNull(GraphQLBoolean), - description: 'True if the reflection was not removed, else false', - resolve: ({isActive}) => !!isActive - }, - isViewerCreator: { - description: 'true if the viewer (userId) is the creator of the retro reflection, else false', - type: new GraphQLNonNull(GraphQLBoolean), - resolve: ({creatorId}, _args: unknown, {authToken}) => { - const viewerId = getUserId(authToken) - return viewerId === creatorId - } - }, - content: { - description: 'The stringified draft-js content', - type: new GraphQLNonNull(GraphQLString) - }, - entities: { - description: - 'The entities (i.e. nouns) parsed from the content and their respective salience', - type: new GraphQLNonNull(new GraphQLList(new GraphQLNonNull(GoogleAnalyzedEntity))), - resolve: resolveForSU('entities') - }, - meetingId: { - type: new GraphQLNonNull(GraphQLID), - description: 'The foreign key to link a reflection to its meeting' - }, - meeting: { - type: new GraphQLNonNull(RetrospectiveMeeting), - description: 'The retrospective meeting this reflection was created in', - resolve: ({meetingId}, _args: unknown, {dataLoader}) => { - return dataLoader.get('newMeetings').load(meetingId) - } - }, - plaintextContent: { - description: 'The plaintext version of content', - type: new GraphQLNonNull(GraphQLString) - }, - promptId: { - type: new GraphQLNonNull(GraphQLID), - description: - 'The foreign key to link a reflection to its prompt. Immutable. For sorting, use prompt on the group.' - }, - prompt: { - type: new GraphQLNonNull(ReflectPrompt), - resolve: ({promptId}, _args: unknown, {dataLoader}) => { - return dataLoader.get('reflectPrompts').load(promptId) - } - }, - reactjis: { - type: new GraphQLNonNull(new GraphQLList(new GraphQLNonNull(require('./Reactji').default))), - description: 'All the reactjis for the given reflection', - resolve: resolveReactjis - }, - reflectionGroupId: { - type: new GraphQLNonNull(GraphQLID), - description: 'The foreign key to link a reflection to its group' - }, - retroReflectionGroup: { - type: RetroReflectionGroup, - description: 'The group the reflection belongs to, if any', - resolve: async ({reflectionGroupId}, _args: unknown, {dataLoader}) => { - return dataLoader.get('retroReflectionGroups').load(reflectionGroupId) - } - }, - sortOrder: { - type: new GraphQLNonNull(GraphQLFloat), - description: 'The sort order of the reflection in the group (increments starting from 0)' - }, - team: { - type: new GraphQLNonNull(Team), - description: 'The team that is running the meeting that contains this reflection', - resolve: async ({meetingId}, _args: unknown, {dataLoader}) => { - const meeting = await dataLoader.get('newMeetings').load(meetingId) - return dataLoader.get('teams').load(meeting.teamId) - } - }, - updatedAt: { - type: GraphQLISO8601Type, - description: - 'The timestamp the meeting was updated. Used to determine how long it took to write a reflection' - } - }) + fields: {} }) export default RetroReflection diff --git a/packages/server/graphql/types/RetroReflectionGroup.ts b/packages/server/graphql/types/RetroReflectionGroup.ts index 7fb07754d0c..ffc1e5d525c 100644 --- a/packages/server/graphql/types/RetroReflectionGroup.ts +++ b/packages/server/graphql/types/RetroReflectionGroup.ts @@ -8,7 +8,6 @@ import { GraphQLObjectType, GraphQLString } from 'graphql' -import Reflection from '../../database/types/Reflection' import {getUserId} from '../../utils/authorization' import {GQLContext} from '../graphql' import {resolveForSU} from '../resolvers' @@ -70,11 +69,9 @@ const RetroReflectionGroup: GraphQLObjectType = new GraphQLObjectType reflection.reflectionGroupId === reflectionGroupId - ) - filteredReflections.sort((a: Reflection, b: Reflection) => - a.sortOrder < b.sortOrder ? 1 : -1 + (reflection) => reflection.reflectionGroupId === reflectionGroupId ) + filteredReflections.sort((a, b) => (a.sortOrder < b.sortOrder ? 1 : -1)) return filteredReflections } }, diff --git a/packages/server/graphql/types/User.ts b/packages/server/graphql/types/User.ts index 22fd242a3c2..f26db140ccb 100644 --- a/packages/server/graphql/types/User.ts +++ b/packages/server/graphql/types/User.ts @@ -20,7 +20,6 @@ import {RDatum} from '../../database/stricterR' import MeetingMemberType from '../../database/types/MeetingMember' import OrganizationType from '../../database/types/Organization' import OrganizationUserType from '../../database/types/OrganizationUser' -import Reflection from '../../database/types/Reflection' import SuggestedActionType from '../../database/types/SuggestedAction' import TimelineEvent from '../../database/types/TimelineEvent' import {getUserId, isSuperUser, isTeamMember} from '../../utils/authorization' @@ -478,7 +477,7 @@ const User: GraphQLObjectType = new GraphQLObjectType + dataLoader.get('retroReflectionsByMeetingId').load(meetingId) ]) if (!viewerMeetingMember) { return standardError(new Error('Not on team'), {userId}) @@ -489,7 +488,7 @@ const User: GraphQLObjectType = new GraphQLObjectType groupId !== reflectionGroupId + ({reflectionGroupId: groupId}) => groupId !== reflectionGroupId ) const relatedGroupIds = [ ...new Set(relatedReflections.map(({reflectionGroupId}) => reflectionGroupId)) diff --git a/packages/server/utils/OpenAIServerManager.ts b/packages/server/utils/OpenAIServerManager.ts index 6356c486e6f..b39615807af 100644 --- a/packages/server/utils/OpenAIServerManager.ts +++ b/packages/server/utils/OpenAIServerManager.ts @@ -1,7 +1,7 @@ import JSON5 from 'json5' import OpenAI from 'openai' -import Reflection from '../database/types/Reflection' import {ModifyType} from '../graphql/public/resolverTypes' +import {RetroReflectionSource} from '../graphql/public/types/RetroReflection' import {Logger} from './Logger' import sendToSentry from './sendToSentry' @@ -107,7 +107,7 @@ class OpenAIServerManager { } } - async getDiscussionPromptQuestion(topic: string, reflections: Reflection[]) { + async getDiscussionPromptQuestion(topic: string, reflections: RetroReflectionSource[]) { if (!this.openAIApi) return null const prompt = `As the meeting facilitator, your task is to steer the discussion in a productive direction. I will provide you with a topic and comments made by the participants around that topic. Your job is to generate a thought-provoking question based on these inputs. Here's how to do it step by step: diff --git a/packages/server/utils/getGroupedReactjis.ts b/packages/server/utils/getGroupedReactjis.ts index 0d3c46adc9d..6eda683da36 100644 --- a/packages/server/utils/getGroupedReactjis.ts +++ b/packages/server/utils/getGroupedReactjis.ts @@ -1,9 +1,9 @@ import ReactjiId from 'parabol-client/shared/gqlIds/ReactjiId' import Reactji from '../database/types/Reactji' -import {ReactjiType} from '../graphql/types/Reactji' +import {ReactjiSource} from '../graphql/public/types/Reactji' const getGroupedReactjis = (reactjis: Reactji[], viewerId: string, idPrefix: string) => { - const agg = {} as {[key: string]: ReactjiType} + const agg = {} as {[key: string]: ReactjiSource} reactjis.forEach((reactji) => { const {id, userId} = reactji const guid = ReactjiId.join(idPrefix, id) From 6689e3990ce8fbcbc3edab53448a006083425fac Mon Sep 17 00:00:00 2001 From: Matt Krick Date: Tue, 25 Jun 2024 12:23:10 -0700 Subject: [PATCH 18/47] fix: self-review Signed-off-by: Matt Krick --- packages/server/graphql/mutations/createReflection.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/server/graphql/mutations/createReflection.ts b/packages/server/graphql/mutations/createReflection.ts index 577eef6b48a..43b55797825 100644 --- a/packages/server/graphql/mutations/createReflection.ts +++ b/packages/server/graphql/mutations/createReflection.ts @@ -84,7 +84,6 @@ export default { plaintextContent, entities, sentimentScore, - isActive: true, meetingId, promptId, reflectionGroupId From 50ca56b885d313aa10471dfe03f09dc4dbb1b8cc Mon Sep 17 00:00:00 2001 From: Matt Krick Date: Tue, 25 Jun 2024 12:55:43 -0700 Subject: [PATCH 19/47] fix: migration order rename Signed-off-by: Matt Krick --- ...flection-phase2.ts => 1719162354561_RetroReflection-phase2.ts} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename packages/server/postgres/migrations/{1717606963897_RetroReflection-phase2.ts => 1719162354561_RetroReflection-phase2.ts} (100%) diff --git a/packages/server/postgres/migrations/1717606963897_RetroReflection-phase2.ts b/packages/server/postgres/migrations/1719162354561_RetroReflection-phase2.ts similarity index 100% rename from packages/server/postgres/migrations/1717606963897_RetroReflection-phase2.ts rename to packages/server/postgres/migrations/1719162354561_RetroReflection-phase2.ts From 22ca4f32cd257350bbd7e02c881f5045c9082f25 Mon Sep 17 00:00:00 2001 From: Matt Krick Date: Tue, 25 Jun 2024 14:34:46 -0700 Subject: [PATCH 20/47] chore: write to PG Signed-off-by: Matt Krick --- .../graphql/mutations/archiveTimelineEvent.ts | 11 +++- .../server/graphql/mutations/endCheckIn.ts | 7 ++- .../graphql/mutations/endSprintPoker.ts | 7 ++- .../mutations/helpers/bootstrapNewUser.ts | 8 +-- .../mutations/helpers/createTeamAndLeader.ts | 3 ++ .../mutations/helpers/safeEndRetrospective.ts | 7 ++- .../mutations/helpers/safeEndTeamPrompt.ts | 7 ++- .../1719348524673_TimelineEvent-phase1.ts | 53 +++++++++++++++++++ 8 files changed, 94 insertions(+), 9 deletions(-) create mode 100644 packages/server/postgres/migrations/1719348524673_TimelineEvent-phase1.ts diff --git a/packages/server/graphql/mutations/archiveTimelineEvent.ts b/packages/server/graphql/mutations/archiveTimelineEvent.ts index 7a30e9f89b6..f116011eece 100644 --- a/packages/server/graphql/mutations/archiveTimelineEvent.ts +++ b/packages/server/graphql/mutations/archiveTimelineEvent.ts @@ -4,6 +4,7 @@ import TimelineEventCheckinComplete from 'parabol-server/database/types/Timeline import TimelineEventRetroComplete from 'parabol-server/database/types/TimelineEventRetroComplete' import getRethink from '../../database/rethinkDriver' import {TimelineEventEnum} from '../../database/types/TimelineEvent' +import getKysely from '../../postgres/getKysely' import {getUserId, isTeamMember} from '../../utils/authorization' import publish from '../../utils/publish' import standardError from '../../utils/standardError' @@ -57,7 +58,15 @@ const archiveTimelineEvent = { .get('timelineEventsByMeetingId') .load(meetingId) const eventIds = meetingTimelineEvents.map(({id}) => id) - await r.table('TimelineEvent').getAll(r.args(eventIds)).update({isActive: false}).run() + const pg = getKysely() + await Promise.all([ + pg + .updateTable('TimelineEvent') + .set({isActive: false}) + .where('id', 'in', eventIds) + .execute(), + r.table('TimelineEvent').getAll(r.args(eventIds)).update({isActive: false}).run() + ]) meetingTimelineEvents.map((event) => { const {id: timelineEventId, userId} = event publish( diff --git a/packages/server/graphql/mutations/endCheckIn.ts b/packages/server/graphql/mutations/endCheckIn.ts index 38b63ef12d0..71a0b38313e 100644 --- a/packages/server/graphql/mutations/endCheckIn.ts +++ b/packages/server/graphql/mutations/endCheckIn.ts @@ -11,6 +11,7 @@ import MeetingAction from '../../database/types/MeetingAction' import Task from '../../database/types/Task' import TimelineEventCheckinComplete from '../../database/types/TimelineEventCheckinComplete' import generateUID from '../../generateUID' +import getKysely from '../../postgres/getKysely' import archiveTasksForDB from '../../safeMutations/archiveTasksForDB' import removeSuggestedAction from '../../safeMutations/removeSuggestedAction' import {Logger} from '../../utils/Logger' @@ -244,7 +245,11 @@ export default { }) ) const timelineEventId = events[0]!.id - await r.table('TimelineEvent').insert(events).run() + const pg = getKysely() + await Promise.all([ + pg.insertInto('TimelineEvent').values(events).execute(), + r.table('TimelineEvent').insert(events).run() + ]) if (team.isOnboardTeam) { const teamLeadUserId = await r .table('TeamMember') diff --git a/packages/server/graphql/mutations/endSprintPoker.ts b/packages/server/graphql/mutations/endSprintPoker.ts index 14fa0fbb449..250fc524130 100644 --- a/packages/server/graphql/mutations/endSprintPoker.ts +++ b/packages/server/graphql/mutations/endSprintPoker.ts @@ -7,6 +7,7 @@ import getRethink from '../../database/rethinkDriver' import Meeting from '../../database/types/Meeting' import MeetingPoker from '../../database/types/MeetingPoker' import TimelineEventPokerComplete from '../../database/types/TimelineEventPokerComplete' +import getKysely from '../../postgres/getKysely' import {Logger} from '../../utils/Logger' import {analytics} from '../../utils/analytics/analytics' import {getUserId, isSuperUser, isTeamMember} from '../../utils/authorization' @@ -127,7 +128,11 @@ export default { meetingId }) ) - await r.table('TimelineEvent').insert(events).run() + const pg = getKysely() + await Promise.all([ + pg.insertInto('TimelineEvent').values(events).execute(), + r.table('TimelineEvent').insert(events).run() + ]) const data = { meetingId, diff --git a/packages/server/graphql/mutations/helpers/bootstrapNewUser.ts b/packages/server/graphql/mutations/helpers/bootstrapNewUser.ts index b3a79ed611f..ebf0153fe3d 100644 --- a/packages/server/graphql/mutations/helpers/bootstrapNewUser.ts +++ b/packages/server/graphql/mutations/helpers/bootstrapNewUser.ts @@ -6,6 +6,7 @@ import SuggestedActionTryTheDemo from '../../../database/types/SuggestedActionTr import TimelineEventJoinedParabol from '../../../database/types/TimelineEventJoinedParabol' import User from '../../../database/types/User' import generateUID from '../../../generateUID' +import getKysely from '../../../postgres/getKysely' import getUsersbyDomain from '../../../postgres/queries/getUsersByDomain' import insertUser from '../../../postgres/queries/insertUser' import IUser from '../../../postgres/types/IUser' @@ -57,13 +58,12 @@ const bootstrapNewUser = async ( const hasSAMLURL = !!(await getSAMLURLFromEmail(email, dataLoader, false)) const isQualifiedForAutoJoin = (isVerified || hasSAMLURL) && isCompanyDomain const orgIds = organizations.map(({id}) => id) - + const pg = getKysely() const [teamsWithAutoJoinRes] = await Promise.all([ isQualifiedForAutoJoin ? dataLoader.get('autoJoinTeamsByOrgId').loadMany(orgIds) : [], insertUser({...newUser, isPatient0, featureFlags: experimentalFlags}), - r({ - event: r.table('TimelineEvent').insert(joinEvent) - }).run() + pg.insertInto('TimelineEvent').values(joinEvent).execute(), + r.table('TimelineEvent').insert(joinEvent).run() ]) // Identify the user so user properties are set before any events are sent diff --git a/packages/server/graphql/mutations/helpers/createTeamAndLeader.ts b/packages/server/graphql/mutations/helpers/createTeamAndLeader.ts index 2e972dd3e6f..3032de65c47 100644 --- a/packages/server/graphql/mutations/helpers/createTeamAndLeader.ts +++ b/packages/server/graphql/mutations/helpers/createTeamAndLeader.ts @@ -4,6 +4,7 @@ import MeetingSettingsPoker from '../../../database/types/MeetingSettingsPoker' import MeetingSettingsRetrospective from '../../../database/types/MeetingSettingsRetrospective' import Team from '../../../database/types/Team' import TimelineEventCreatedTeam from '../../../database/types/TimelineEventCreatedTeam' +import getKysely from '../../../postgres/getKysely' import getPg from '../../../postgres/getPg' import {insertTeamQuery} from '../../../postgres/queries/generated/insertTeamQuery' import IUser from '../../../postgres/types/IUser' @@ -38,12 +39,14 @@ export default async function createTeamAndLeader(user: IUser, newTeam: ValidNew orgId }) + const pg = getKysely() await Promise.all([ catchAndLog(() => insertTeamQuery.run(verifiedTeam, getPg())), // add meeting settings r.table('MeetingSettings').insert(meetingSettings).run(), // denormalize common fields to team member insertNewTeamMember(user, teamId), + pg.insertInto('TimelineEvent').values(timelineEvent).execute(), r.table('TimelineEvent').insert(timelineEvent).run(), addTeamIdToTMS(userId, teamId) ]) diff --git a/packages/server/graphql/mutations/helpers/safeEndRetrospective.ts b/packages/server/graphql/mutations/helpers/safeEndRetrospective.ts index 03715f20bd3..0467adf031b 100644 --- a/packages/server/graphql/mutations/helpers/safeEndRetrospective.ts +++ b/packages/server/graphql/mutations/helpers/safeEndRetrospective.ts @@ -7,6 +7,7 @@ import getRethink from '../../../database/rethinkDriver' import {RDatum} from '../../../database/stricterR' import MeetingRetrospective from '../../../database/types/MeetingRetrospective' import TimelineEventRetroComplete from '../../../database/types/TimelineEventRetroComplete' +import getKysely from '../../../postgres/getKysely' import removeSuggestedAction from '../../../safeMutations/removeSuggestedAction' import {Logger} from '../../../utils/Logger' import RecallAIServerManager from '../../../utils/RecallAIServerManager' @@ -163,7 +164,11 @@ const safeEndRetrospective = async ({ }) ) const timelineEventId = events[0]!.id - await r.table('TimelineEvent').insert(events).run() + const pg = getKysely() + await Promise.all([ + pg.insertInto('TimelineEvent').values(events).execute(), + r.table('TimelineEvent').insert(events).run() + ]) if (team.isOnboardTeam) { const teamLeadUserId = await r diff --git a/packages/server/graphql/mutations/helpers/safeEndTeamPrompt.ts b/packages/server/graphql/mutations/helpers/safeEndTeamPrompt.ts index 7688a32979f..57cc38f293f 100644 --- a/packages/server/graphql/mutations/helpers/safeEndTeamPrompt.ts +++ b/packages/server/graphql/mutations/helpers/safeEndTeamPrompt.ts @@ -3,6 +3,7 @@ import {checkTeamsLimit} from '../../../billing/helpers/teamLimitsCheck' import getRethink, {ParabolR} from '../../../database/rethinkDriver' import MeetingTeamPrompt from '../../../database/types/MeetingTeamPrompt' import TimelineEventTeamPromptComplete from '../../../database/types/TimelineEventTeamPromptComplete' +import getKysely from '../../../postgres/getKysely' import {getTeamPromptResponsesByMeetingId} from '../../../postgres/queries/getTeamPromptResponsesByMeetingIds' import {Logger} from '../../../utils/Logger' import {analytics} from '../../../utils/analytics/analytics' @@ -102,7 +103,11 @@ const safeEndTeamPrompt = async ({ }) ) const timelineEventId = events[0]!.id - await r.table('TimelineEvent').insert(events).run() + const pg = getKysely() + await Promise.all([ + pg.insertInto('TimelineEvent').values(events).execute(), + r.table('TimelineEvent').insert(events).run() + ]) summarizeTeamPrompt(meeting, context) analytics.teamPromptEnd(completedTeamPrompt, meetingMembers, responses, dataLoader) checkTeamsLimit(team.orgId, dataLoader) diff --git a/packages/server/postgres/migrations/1719348524673_TimelineEvent-phase1.ts b/packages/server/postgres/migrations/1719348524673_TimelineEvent-phase1.ts new file mode 100644 index 00000000000..97eac9cfd24 --- /dev/null +++ b/packages/server/postgres/migrations/1719348524673_TimelineEvent-phase1.ts @@ -0,0 +1,53 @@ +import {Client} from 'pg' +import getPgConfig from '../getPgConfig' + +export async function up() { + const client = new Client(getPgConfig()) + await client.connect() + // teamId, orgId, meetingId + await client.query(` + DO $$ + BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'TimelineEventEnum') THEN + CREATE TYPE "TimelineEventEnum" AS ENUM ( + 'TEAM_PROMPT_COMPLETE', + 'POKER_COMPLETE', + 'actionComplete', + 'createdTeam', + 'joinedParabol', + 'retroComplete' + ); + END IF; + + CREATE TABLE IF NOT EXISTS "TimelineEvent" ( + "id" VARCHAR(100) PRIMARY KEY, + "createdAt" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(), + "interactionCount" SMALLINT NOT NULL DEFAULT 0, + "seenCount" SMALLINT NOT NULL DEFAULT 0, + "type" "TimelineEventEnum" NOT NULL, + "userId" VARCHAR(100) NOT NULL, + "teamId" VARCHAR(100), + "orgId" VARCHAR(100), + "meetingId" VARCHAR(100), + "isActive" BOOLEAN NOT NULL DEFAULT TRUE, + CONSTRAINT "fk_userId" + FOREIGN KEY("userId") + REFERENCES "User"("id") + ON DELETE CASCADE + ); + CREATE INDEX IF NOT EXISTS "idx_TimelineEvent_userId_createdAt" ON "TimelineEvent"("userId", "createdAt") WHERE "isActive" = TRUE; + CREATE INDEX IF NOT EXISTS "idx_TimelineEvent_meetingId" ON "TimelineEvent"("meetingId"); + END $$; +`) + await client.end() +} + +export async function down() { + const client = new Client(getPgConfig()) + await client.connect() + await client.query(` + DROP TABLE "TimelineEvent"; + DROP TYPE "TimelineEventEnum"; + ` /* Do undo magic */) + await client.end() +} From 9c55acddf33aca651d27fd98feb6b804235fb0b0 Mon Sep 17 00:00:00 2001 From: Matt Krick Date: Tue, 25 Jun 2024 15:48:35 -0700 Subject: [PATCH 21/47] chore: migrate old records to PG Signed-off-by: Matt Krick --- .../1719351990570_TimelineEvent-phase2.ts | 103 ++++++++++++++++++ 1 file changed, 103 insertions(+) create mode 100644 packages/server/postgres/migrations/1719351990570_TimelineEvent-phase2.ts diff --git a/packages/server/postgres/migrations/1719351990570_TimelineEvent-phase2.ts b/packages/server/postgres/migrations/1719351990570_TimelineEvent-phase2.ts new file mode 100644 index 00000000000..5f70015a119 --- /dev/null +++ b/packages/server/postgres/migrations/1719351990570_TimelineEvent-phase2.ts @@ -0,0 +1,103 @@ +import {Kysely, PostgresDialect} from 'kysely' +import {r} from 'rethinkdb-ts' +import connectRethinkDB from '../../database/connectRethinkDB' +import getPg from '../getPg' + +export async function up() { + await connectRethinkDB() + const pg = new Kysely({ + dialect: new PostgresDialect({ + pool: getPg() + }) + }) + try { + console.log('Adding index') + await r + .table('TimelineEvent') + .indexCreate('createdAtId', (row: any) => [row('createdAt'), row('id')]) + .run() + await r.table('TimelineEvent').indexWait().run() + } catch { + // index already exists + } + console.log('Adding index complete') + const MAX_PG_PARAMS = 65545 + const PG_COLS = [ + 'id', + 'createdAt', + 'interactionCount', + 'seenCount', + 'type', + 'userId', + 'teamId', + 'orgId', + 'meetingId', + 'isActive' + ] as const + type TimelineEvent = { + [K in (typeof PG_COLS)[number]]: any + } + const BATCH_SIZE = Math.trunc(MAX_PG_PARAMS / PG_COLS.length) + + let curUpdatedAt = r.minval + let curId = r.minval + for (let i = 0; i < 1e6; i++) { + console.log('inserting row', i * BATCH_SIZE, curUpdatedAt, curId) + const rowsToInsert = (await r + .table('TimelineEvent') + .between([curUpdatedAt, curId], [r.maxval, r.maxval], { + index: 'createdAtId', + leftBound: 'open', + rightBound: 'closed' + }) + .orderBy({index: 'createdAtId'}) + .limit(BATCH_SIZE) + .pluck(...PG_COLS) + .run()) as TimelineEvent[] + + if (rowsToInsert.length === 0) break + const lastRow = rowsToInsert[rowsToInsert.length - 1] + curUpdatedAt = lastRow.createdAt + curId = lastRow.id + try { + await pg + .insertInto('TimelineEvent') + .values(rowsToInsert) + .onConflict((oc) => oc.doNothing()) + .execute() + } catch (e) { + await Promise.all( + rowsToInsert.map(async (row) => { + try { + const res = await pg + .insertInto('TimelineEvent') + .values(row) + .onConflict((oc) => oc.doNothing()) + .execute() + } catch (e) { + if (e.constraint === 'fk_userId' || e.constraint === 'fk_teamId') { + // console.log(`Skipping ${row.id} because it has no user/team`) + return + } + console.log(e, row) + } + }) + ) + } + } +} + +export async function down() { + await connectRethinkDB() + try { + await r.table('TimelineEvent').indexDrop('createdAtId').run() + } catch { + // index already dropped + } + const pg = new Kysely({ + dialect: new PostgresDialect({ + pool: getPg() + }) + }) + await pg.deleteFrom('TimelineEvent').execute() +} From 9dfd35ca1e36f266652b2fd338a751a9a55d8440 Mon Sep 17 00:00:00 2001 From: Matt Krick Date: Tue, 25 Jun 2024 15:51:42 -0700 Subject: [PATCH 22/47] fix: add teamid FK Signed-off-by: Matt Krick --- .../postgres/migrations/1719348524673_TimelineEvent-phase1.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/server/postgres/migrations/1719348524673_TimelineEvent-phase1.ts b/packages/server/postgres/migrations/1719348524673_TimelineEvent-phase1.ts index 97eac9cfd24..b5c9a458bc0 100644 --- a/packages/server/postgres/migrations/1719348524673_TimelineEvent-phase1.ts +++ b/packages/server/postgres/migrations/1719348524673_TimelineEvent-phase1.ts @@ -33,6 +33,10 @@ export async function up() { CONSTRAINT "fk_userId" FOREIGN KEY("userId") REFERENCES "User"("id") + ON DELETE CASCADE, + CONSTRAINT "fk_teamId" + FOREIGN KEY("teamId") + REFERENCES "Team"("id") ON DELETE CASCADE ); CREATE INDEX IF NOT EXISTS "idx_TimelineEvent_userId_createdAt" ON "TimelineEvent"("userId", "createdAt") WHERE "isActive" = TRUE; From ebbea29ae55a69540e3f90525ad33656213ec9f8 Mon Sep 17 00:00:00 2001 From: Matt Krick Date: Tue, 25 Jun 2024 16:43:58 -0700 Subject: [PATCH 23/47] chore: read from pg Signed-off-by: Matt Krick --- packages/server/database/rethinkDriver.ts | 5 --- .../dataloader/foreignKeyLoaderMakers.ts | 14 ++++++++ .../dataloader/primaryKeyLoaderMakers.ts | 4 +++ .../rethinkForeignKeyLoaderMakers.ts | 14 -------- .../rethinkPrimaryKeyLoaderMakers.ts | 1 - .../graphql/mutations/archiveTimelineEvent.ts | 15 +++----- .../server/graphql/mutations/endCheckIn.ts | 5 +-- .../graphql/mutations/endSprintPoker.ts | 5 +-- .../mutations/helpers/bootstrapNewUser.ts | 3 +- .../mutations/helpers/createTeamAndLeader.ts | 1 - .../mutations/helpers/safeEndRetrospective.ts | 5 +-- .../mutations/helpers/safeEndTeamPrompt.ts | 5 +-- .../private/mutations/backupOrganization.ts | 12 +------ .../private/mutations/hardDeleteUser.ts | 6 ---- packages/server/graphql/types/User.ts | 35 +++++++++---------- 15 files changed, 46 insertions(+), 84 deletions(-) diff --git a/packages/server/database/rethinkDriver.ts b/packages/server/database/rethinkDriver.ts index d66cde79213..d88791af467 100644 --- a/packages/server/database/rethinkDriver.ts +++ b/packages/server/database/rethinkDriver.ts @@ -36,7 +36,6 @@ import SuggestedActionTryTheDemo from './types/SuggestedActionTryTheDemo' import Task from './types/Task' import TemplateDimension from './types/TemplateDimension' import TemplateScale from './types/TemplateScale' -import TimelineEvent from './types/TimelineEvent' export type RethinkSchema = { AgendaItem: { @@ -187,10 +186,6 @@ export type RethinkSchema = { type: TemplateScale index: 'teamId' } - TimelineEvent: { - type: TimelineEvent - index: 'userIdCreatedAt' | 'meetingId' - } } export type DBType = { diff --git a/packages/server/dataloader/foreignKeyLoaderMakers.ts b/packages/server/dataloader/foreignKeyLoaderMakers.ts index dc57123c89a..9bfccba0e94 100644 --- a/packages/server/dataloader/foreignKeyLoaderMakers.ts +++ b/packages/server/dataloader/foreignKeyLoaderMakers.ts @@ -60,3 +60,17 @@ export const retroReflectionsByGroupId = foreignKeyLoaderMaker( .execute() } ) + +export const timelineEventsByMeetingId = foreignKeyLoaderMaker( + 'timelineEvents', + 'meetingId', + async (meetingIds) => { + const pg = getKysely() + return pg + .selectFrom('TimelineEvent') + .selectAll() + .where('meetingId', 'in', meetingIds) + .where('isActive', '=', true) + .execute() + } +) diff --git a/packages/server/dataloader/primaryKeyLoaderMakers.ts b/packages/server/dataloader/primaryKeyLoaderMakers.ts index a3f096efbda..dcb6eb8ac0a 100644 --- a/packages/server/dataloader/primaryKeyLoaderMakers.ts +++ b/packages/server/dataloader/primaryKeyLoaderMakers.ts @@ -53,3 +53,7 @@ export const selectRetroReflections = () => export const retroReflections = primaryKeyLoaderMaker((ids: readonly string[]) => { return selectRetroReflections().where('id', 'in', ids).execute() }) + +export const timelineEvents = primaryKeyLoaderMaker((ids: readonly string[]) => { + return getKysely().selectFrom('TimelineEvent').selectAll().where('id', 'in', ids).execute() +}) diff --git a/packages/server/dataloader/rethinkForeignKeyLoaderMakers.ts b/packages/server/dataloader/rethinkForeignKeyLoaderMakers.ts index 24a4abe50d0..461c58dd606 100644 --- a/packages/server/dataloader/rethinkForeignKeyLoaderMakers.ts +++ b/packages/server/dataloader/rethinkForeignKeyLoaderMakers.ts @@ -1,5 +1,3 @@ -import TimelineEventCheckinComplete from 'parabol-server/database/types/TimelineEventCheckinComplete' -import TimelineEventRetroComplete from 'parabol-server/database/types/TimelineEventRetroComplete' import getRethink from '../database/rethinkDriver' import {RDatum} from '../database/stricterR' import RethinkForeignKeyLoaderMaker from './RethinkForeignKeyLoaderMaker' @@ -182,18 +180,6 @@ export const templateDimensionsByTemplateId = new RethinkForeignKeyLoaderMaker( ) } ) -export const timelineEventsByMeetingId = new RethinkForeignKeyLoaderMaker( - 'timelineEvents', - 'meetingId', - async (meetingIds) => { - const r = await getRethink() - return r - .table('TimelineEvent') - .getAll(r.args(meetingIds), {index: 'meetingId'}) - .filter({isActive: true}) - .run() as Promise - } -) export const slackAuthByUserId = new RethinkForeignKeyLoaderMaker( 'slackAuths', diff --git a/packages/server/dataloader/rethinkPrimaryKeyLoaderMakers.ts b/packages/server/dataloader/rethinkPrimaryKeyLoaderMakers.ts index 1a4efd5417a..d5dbd237079 100644 --- a/packages/server/dataloader/rethinkPrimaryKeyLoaderMakers.ts +++ b/packages/server/dataloader/rethinkPrimaryKeyLoaderMakers.ts @@ -23,4 +23,3 @@ export const tasks = new RethinkPrimaryKeyLoaderMaker('Task') export const teamMembers = new RethinkPrimaryKeyLoaderMaker('TeamMember') export const teamInvitations = new RethinkPrimaryKeyLoaderMaker('TeamInvitation') export const templateDimensions = new RethinkPrimaryKeyLoaderMaker('TemplateDimension') -export const timelineEvents = new RethinkPrimaryKeyLoaderMaker('TimelineEvent') diff --git a/packages/server/graphql/mutations/archiveTimelineEvent.ts b/packages/server/graphql/mutations/archiveTimelineEvent.ts index f116011eece..bbada88ed8b 100644 --- a/packages/server/graphql/mutations/archiveTimelineEvent.ts +++ b/packages/server/graphql/mutations/archiveTimelineEvent.ts @@ -2,7 +2,6 @@ import {GraphQLID, GraphQLNonNull} from 'graphql' import {SubscriptionChannel} from 'parabol-client/types/constEnums' import TimelineEventCheckinComplete from 'parabol-server/database/types/TimelineEventCheckinComplete' import TimelineEventRetroComplete from 'parabol-server/database/types/TimelineEventRetroComplete' -import getRethink from '../../database/rethinkDriver' import {TimelineEventEnum} from '../../database/types/TimelineEvent' import getKysely from '../../postgres/getKysely' import {getUserId, isTeamMember} from '../../utils/authorization' @@ -25,7 +24,6 @@ const archiveTimelineEvent = { {timelineEventId}: {timelineEventId: string}, {authToken, dataLoader, socketId: mutatorId}: GQLContext ) => { - const r = await getRethink() const operationId = dataLoader.share() const subOptions = {mutatorId, operationId} const viewerId = getUserId(authToken) @@ -59,14 +57,11 @@ const archiveTimelineEvent = { .load(meetingId) const eventIds = meetingTimelineEvents.map(({id}) => id) const pg = getKysely() - await Promise.all([ - pg - .updateTable('TimelineEvent') - .set({isActive: false}) - .where('id', 'in', eventIds) - .execute(), - r.table('TimelineEvent').getAll(r.args(eventIds)).update({isActive: false}).run() - ]) + await pg + .updateTable('TimelineEvent') + .set({isActive: false}) + .where('id', 'in', eventIds) + .execute() meetingTimelineEvents.map((event) => { const {id: timelineEventId, userId} = event publish( diff --git a/packages/server/graphql/mutations/endCheckIn.ts b/packages/server/graphql/mutations/endCheckIn.ts index 71a0b38313e..c97e2f7cd03 100644 --- a/packages/server/graphql/mutations/endCheckIn.ts +++ b/packages/server/graphql/mutations/endCheckIn.ts @@ -246,10 +246,7 @@ export default { ) const timelineEventId = events[0]!.id const pg = getKysely() - await Promise.all([ - pg.insertInto('TimelineEvent').values(events).execute(), - r.table('TimelineEvent').insert(events).run() - ]) + await pg.insertInto('TimelineEvent').values(events).execute() if (team.isOnboardTeam) { const teamLeadUserId = await r .table('TeamMember') diff --git a/packages/server/graphql/mutations/endSprintPoker.ts b/packages/server/graphql/mutations/endSprintPoker.ts index 250fc524130..267aa3eb4cf 100644 --- a/packages/server/graphql/mutations/endSprintPoker.ts +++ b/packages/server/graphql/mutations/endSprintPoker.ts @@ -129,10 +129,7 @@ export default { }) ) const pg = getKysely() - await Promise.all([ - pg.insertInto('TimelineEvent').values(events).execute(), - r.table('TimelineEvent').insert(events).run() - ]) + await pg.insertInto('TimelineEvent').values(events).execute() const data = { meetingId, diff --git a/packages/server/graphql/mutations/helpers/bootstrapNewUser.ts b/packages/server/graphql/mutations/helpers/bootstrapNewUser.ts index ebf0153fe3d..a69f943ac0b 100644 --- a/packages/server/graphql/mutations/helpers/bootstrapNewUser.ts +++ b/packages/server/graphql/mutations/helpers/bootstrapNewUser.ts @@ -62,8 +62,7 @@ const bootstrapNewUser = async ( const [teamsWithAutoJoinRes] = await Promise.all([ isQualifiedForAutoJoin ? dataLoader.get('autoJoinTeamsByOrgId').loadMany(orgIds) : [], insertUser({...newUser, isPatient0, featureFlags: experimentalFlags}), - pg.insertInto('TimelineEvent').values(joinEvent).execute(), - r.table('TimelineEvent').insert(joinEvent).run() + pg.insertInto('TimelineEvent').values(joinEvent).execute() ]) // Identify the user so user properties are set before any events are sent diff --git a/packages/server/graphql/mutations/helpers/createTeamAndLeader.ts b/packages/server/graphql/mutations/helpers/createTeamAndLeader.ts index 3032de65c47..0fb55778743 100644 --- a/packages/server/graphql/mutations/helpers/createTeamAndLeader.ts +++ b/packages/server/graphql/mutations/helpers/createTeamAndLeader.ts @@ -47,7 +47,6 @@ export default async function createTeamAndLeader(user: IUser, newTeam: ValidNew // denormalize common fields to team member insertNewTeamMember(user, teamId), pg.insertInto('TimelineEvent').values(timelineEvent).execute(), - r.table('TimelineEvent').insert(timelineEvent).run(), addTeamIdToTMS(userId, teamId) ]) } diff --git a/packages/server/graphql/mutations/helpers/safeEndRetrospective.ts b/packages/server/graphql/mutations/helpers/safeEndRetrospective.ts index 0467adf031b..a76ba6fe7c8 100644 --- a/packages/server/graphql/mutations/helpers/safeEndRetrospective.ts +++ b/packages/server/graphql/mutations/helpers/safeEndRetrospective.ts @@ -165,10 +165,7 @@ const safeEndRetrospective = async ({ ) const timelineEventId = events[0]!.id const pg = getKysely() - await Promise.all([ - pg.insertInto('TimelineEvent').values(events).execute(), - r.table('TimelineEvent').insert(events).run() - ]) + await pg.insertInto('TimelineEvent').values(events).execute() if (team.isOnboardTeam) { const teamLeadUserId = await r diff --git a/packages/server/graphql/mutations/helpers/safeEndTeamPrompt.ts b/packages/server/graphql/mutations/helpers/safeEndTeamPrompt.ts index 57cc38f293f..e88ed41daf1 100644 --- a/packages/server/graphql/mutations/helpers/safeEndTeamPrompt.ts +++ b/packages/server/graphql/mutations/helpers/safeEndTeamPrompt.ts @@ -104,10 +104,7 @@ const safeEndTeamPrompt = async ({ ) const timelineEventId = events[0]!.id const pg = getKysely() - await Promise.all([ - pg.insertInto('TimelineEvent').values(events).execute(), - r.table('TimelineEvent').insert(events).run() - ]) + await pg.insertInto('TimelineEvent').values(events).execute() summarizeTeamPrompt(meeting, context) analytics.teamPromptEnd(completedTeamPrompt, meetingMembers, responses, dataLoader) checkTeamsLimit(team.orgId, dataLoader) diff --git a/packages/server/graphql/private/mutations/backupOrganization.ts b/packages/server/graphql/private/mutations/backupOrganization.ts index 4a2687025e0..cfc321db1ad 100644 --- a/packages/server/graphql/private/mutations/backupOrganization.ts +++ b/packages/server/graphql/private/mutations/backupOrganization.ts @@ -256,17 +256,7 @@ const backupOrganization: MutationResolvers['backupOrganization'] = async (_sour r.or(row('teamId').default(null).eq(null), r(teamIds).contains(row('teamId'))) ) .coerceTo('array') - .do((items: RValue) => r.db(DESTINATION).table('SuggestedAction').insert(items)), - timelineEvent: ( - r - .table('TimelineEvent') - .filter((row: RDatum) => r(userIds).contains(row('userId'))) as any - ) - .filter((row: RValue) => - r.branch(row('teamId'), r(teamIds).contains(row('teamId')), true) - ) - .coerceTo('array') - .do((items: RValue) => r.db(DESTINATION).table('TimelineEvent').insert(items)) + .do((items: RValue) => r.db(DESTINATION).table('SuggestedAction').insert(items)) }) }), meetingIds: r diff --git a/packages/server/graphql/private/mutations/hardDeleteUser.ts b/packages/server/graphql/private/mutations/hardDeleteUser.ts index da6a190260c..22d3084aba1 100644 --- a/packages/server/graphql/private/mutations/hardDeleteUser.ts +++ b/packages/server/graphql/private/mutations/hardDeleteUser.ts @@ -117,12 +117,6 @@ const hardDeleteUser: MutationResolvers['hardDeleteUser'] = async ( .getAll(r.args(teamIds), {index: 'teamId'}) .filter((row: RValue) => row('createdBy').eq(userIdToDelete)) .delete(), - timelineEvent: r - .table('TimelineEvent') - .between([userIdToDelete, r.minval], [userIdToDelete, r.maxval], { - index: 'userIdCreatedAt' - }) - .delete(), agendaItem: r .table('AgendaItem') .getAll(r.args(teamIds), {index: 'teamId'}) diff --git a/packages/server/graphql/types/User.ts b/packages/server/graphql/types/User.ts index f26db140ccb..68147575957 100644 --- a/packages/server/graphql/types/User.ts +++ b/packages/server/graphql/types/User.ts @@ -15,13 +15,11 @@ import { MAX_RESULT_GROUP_SIZE } from '../../../client/utils/constants' import groupReflections from '../../../client/utils/smartGroup/groupReflections' -import getRethink from '../../database/rethinkDriver' -import {RDatum} from '../../database/stricterR' import MeetingMemberType from '../../database/types/MeetingMember' import OrganizationType from '../../database/types/Organization' import OrganizationUserType from '../../database/types/OrganizationUser' import SuggestedActionType from '../../database/types/SuggestedAction' -import TimelineEvent from '../../database/types/TimelineEvent' +import getKysely from '../../postgres/getKysely' import {getUserId, isSuperUser, isTeamMember} from '../../utils/authorization' import getMonthlyStreak from '../../utils/getMonthlyStreak' import getRedis from '../../utils/getRedis' @@ -217,7 +215,6 @@ const User: GraphQLObjectType = new GraphQLObjectType { - const r = await getRethink() const viewerId = getUserId(authToken) // VALIDATE @@ -244,21 +241,23 @@ const User: GraphQLObjectType = new GraphQLObjectType) => - eventTypes ? r.expr(eventTypes).contains(t('type')) : true - ) - .filter((t: RDatum) => r.expr(validTeamIds).contains(t('teamId'))) - .orderBy(r.desc('createdAt')) + const dbAfter = after ? new Date(after) : new Date('3000-01-01') + const minVal = new Date(0) + + const pg = getKysely() + const events = await pg + .selectFrom('TimelineEvent') + .selectAll() + .where('userId', '=', viewerId) + .where('createdAt', '>', minVal) + .where('createdAt', '<=', dbAfter) + .where('isActive', '=', true) + .where('teamId', 'in', validTeamIds) + .$if(!!eventTypes, (db) => db.where('type', 'in', eventTypes)) + .orderBy('createdAt') .limit(first + 1) - .coerceTo('array') - .run() + .execute() + const edges = events.slice(0, first).map((node) => ({ cursor: node.createdAt, node From c552ea6ddfc32d39f6e69c858d8b1f78b0d12ecd Mon Sep 17 00:00:00 2001 From: Matt Krick Date: Wed, 26 Jun 2024 13:56:49 -0700 Subject: [PATCH 24/47] fix bad merge Signed-off-by: Matt Krick --- packages/server/graphql/mutations/helpers/createTeamAndLeader.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/server/graphql/mutations/helpers/createTeamAndLeader.ts b/packages/server/graphql/mutations/helpers/createTeamAndLeader.ts index 213c5ce6fb2..c46773ded75 100644 --- a/packages/server/graphql/mutations/helpers/createTeamAndLeader.ts +++ b/packages/server/graphql/mutations/helpers/createTeamAndLeader.ts @@ -47,7 +47,6 @@ export default async function createTeamAndLeader(user: IUser, newTeam: ValidNew r.table('MeetingSettings').insert(meetingSettings).run(), // denormalize common fields to team member insertNewTeamMember(user, teamId), - pg.insertInto('TimelineEvent').values(timelineEvent).execute(), r.table('TimelineEvent').insert(timelineEvent).run(), addTeamIdToTMS(userId, teamId) ]) From b7c092d1f4815c46d3655a57fb8a681ad6f8b3b5 Mon Sep 17 00:00:00 2001 From: Matt Krick Date: Wed, 26 Jun 2024 14:47:58 -0700 Subject: [PATCH 25/47] add migration Signed-off-by: Matt Krick --- .../indexing/orgIdsWithFeatureFlag.ts | 15 ------ .../1719435764047_Organization-phase1.ts | 53 +++++++++++++++++++ 2 files changed, 53 insertions(+), 15 deletions(-) delete mode 100644 packages/embedder/indexing/orgIdsWithFeatureFlag.ts create mode 100644 packages/server/postgres/migrations/1719435764047_Organization-phase1.ts diff --git a/packages/embedder/indexing/orgIdsWithFeatureFlag.ts b/packages/embedder/indexing/orgIdsWithFeatureFlag.ts deleted file mode 100644 index 82d86702e67..00000000000 --- a/packages/embedder/indexing/orgIdsWithFeatureFlag.ts +++ /dev/null @@ -1,15 +0,0 @@ -import getRethink from 'parabol-server/database/rethinkDriver' -import {RDatum} from 'parabol-server/database/stricterR' - -export const orgIdsWithFeatureFlag = async () => { - // I had to add a secondary index to the Organization table to get - // this query to be cheap - const r = await getRethink() - return await r - .table('Organization') - .getAll('relatedDiscussions', {index: 'featureFlagsIndex' as any}) - .filter((r: RDatum) => r('featureFlags').contains('relatedDiscussions')) - .map((r: RDatum) => r('id')) - .coerceTo('array') - .run() -} diff --git a/packages/server/postgres/migrations/1719435764047_Organization-phase1.ts b/packages/server/postgres/migrations/1719435764047_Organization-phase1.ts new file mode 100644 index 00000000000..f14b2e5cbfd --- /dev/null +++ b/packages/server/postgres/migrations/1719435764047_Organization-phase1.ts @@ -0,0 +1,53 @@ +import {Client} from 'pg' +import getPgConfig from '../getPgConfig' + +export async function up() { + const client = new Client(getPgConfig()) + await client.connect() + //activeDomain has a few that are longer than 100 + await client.query(` + DO $$ + BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'CreditCard') THEN + CREATE TYPE "CreditCard" AS (brand text, expiry text, last4 smallint); + END IF; + + CREATE TABLE IF NOT EXISTS "Organization" ( + "id" VARCHAR(100) PRIMARY KEY, + "activeDomain" VARCHAR(100), + "isActiveDomainTouched" BOOLEAN NOT NULL DEFAULT FALSE, + "creditCard" "CreditCard", + "createdAt" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(), + "name" VARCHAR(100) NOT NULL, + "payLaterClickCount" SMALLINT NOT NULL DEFAULT 0, + "periodEnd" TIMESTAMP WITH TIME ZONE, + "periodStart" TIMESTAMP WITH TIME ZONE, + "picture" VARCHAR(2056), + "showConversionModal" BOOLEAN NOT NULL DEFAULT FALSE, + "stripeId" VARCHAR(100), + "stripeSubscriptionId" VARCHAR(100), + "upcomingInvoiceEmailSentAt" TIMESTAMP WITH TIME ZONE, + "tier" "TierEnum" NOT NULL DEFAULT 'starter', + "tierLimitExceededAt" TIMESTAMP WITH TIME ZONE, + "trialStartDate" TIMESTAMP WITH TIME ZONE, + "scheduledLockAt" TIMESTAMP WITH TIME ZONE, + "lockedAt" TIMESTAMP WITH TIME ZONE, + "updatedAt" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(), + "featureFlags" TEXT[] NOT NULL DEFAULT '{}' + ); + CREATE INDEX IF NOT EXISTS "idx_Organization_activeDomain" ON "Organization"("activeDomain"); + CREATE INDEX IF NOT EXISTS "idx_Organization_tier" ON "Organization"("tier"); + END $$; +`) + await client.end() +} + +export async function down() { + const client = new Client(getPgConfig()) + await client.connect() + await client.query(` + DROP TABLE "Organization"; + DROP TYPE "CreditCard"; + ` /* Do undo magic */) + await client.end() +} From fe9efe9369dadb18fedbfb1a803c621379081ba0 Mon Sep 17 00:00:00 2001 From: Matt Krick Date: Thu, 27 Jun 2024 12:40:40 -0700 Subject: [PATCH 26/47] write to pg Signed-off-by: Matt Krick --- .../server/billing/helpers/adjustUserCount.ts | 49 +++++++++---------- .../server/billing/helpers/generateInvoice.ts | 13 +++-- .../helpers/generateUpcomingInvoice.ts | 5 +- .../server/billing/helpers/teamLimitsCheck.ts | 36 ++++++++++---- .../billing/helpers/terminateSubscription.ts | 48 +++++++++++------- .../database/types/GoogleAnalyzedEntity.ts | 7 --- .../database/types/processTeamsLimitsJob.ts | 10 +++- packages/server/graphql/mutations/addOrg.ts | 6 ++- packages/server/graphql/mutations/addTeam.ts | 2 +- .../graphql/mutations/createReflection.ts | 4 +- .../graphql/mutations/downgradeToStarter.ts | 6 +-- .../mutations/helpers/bootstrapNewUser.ts | 2 +- .../graphql/mutations/helpers/createNewOrg.ts | 5 ++ .../mutations/helpers/createTeamAndLeader.ts | 9 +++- .../mutations/helpers/hideConversionModal.ts | 6 +++ .../mutations/helpers/removeFromOrg.ts | 4 +- .../helpers/resolveDowngradeToStarter.ts | 16 ++++-- .../helpers/safeCreateRetrospective.ts | 2 +- .../server/graphql/mutations/moveTeamToOrg.ts | 4 +- packages/server/graphql/mutations/payLater.ts | 8 +++ .../mutations/updateReflectionContent.ts | 4 +- .../private/mutations/changeEmailDomain.ts | 5 ++ .../mutations/draftEnterpriseInvoice.ts | 20 ++++++++ .../graphql/private/mutations/endTrial.ts | 1 + .../private/mutations/flagConversionModal.ts | 12 ++++- .../mutations/sendUpcomingInvoiceEmails.ts | 6 +++ .../mutations/setOrganizationDomain.ts | 13 +++-- .../graphql/private/mutations/startTrial.ts | 5 ++ .../mutations/stripeDeleteSubscription.ts | 7 ++- .../mutations/stripeInvoiceFinalized.ts | 4 +- .../private/mutations/stripeInvoicePaid.ts | 9 ++-- .../private/mutations/stripeSucceedPayment.ts | 9 ++-- .../mutations/stripeUpdateCreditCard.ts | 10 ++++ .../mutations/stripeUpdateSubscription.ts | 8 +++ .../private/mutations/updateOrgFeatureFlag.ts | 24 +++++++-- .../private/mutations/upgradeToTeamTier.ts | 35 ++++++++----- .../mutations/acceptRequestToJoinDomain.ts | 9 ++-- .../mutations/createStripeSubscription.ts | 10 ++++ .../public/mutations/updateCreditCard.ts | 14 ++++++ .../graphql/public/mutations/updateOrg.ts | 6 +++ .../public/mutations/uploadOrgImage.ts | 7 ++- .../graphql/public/types/DomainJoinRequest.ts | 9 ++-- packages/server/graphql/queries/invoices.ts | 2 +- .../server/postgres/helpers/toCreditCard.ts | 6 +++ .../helpers/toGoogleAnalyzedEntity.ts | 6 +++ .../1719435764047_Organization-phase1.ts | 2 + .../safeArchiveEmptyStarterOrganization.ts | 8 ++- .../isRequestToJoinDomainAllowed.test.ts | 7 ++- scripts/toolboxSrc/setIsEnterprise.ts | 11 +++-- 49 files changed, 372 insertions(+), 139 deletions(-) create mode 100644 packages/server/postgres/helpers/toCreditCard.ts create mode 100644 packages/server/postgres/helpers/toGoogleAnalyzedEntity.ts diff --git a/packages/server/billing/helpers/adjustUserCount.ts b/packages/server/billing/helpers/adjustUserCount.ts index 42618bd4261..649f568f161 100644 --- a/packages/server/billing/helpers/adjustUserCount.ts +++ b/packages/server/billing/helpers/adjustUserCount.ts @@ -1,9 +1,10 @@ import {InvoiceItemType} from 'parabol-client/types/constEnums' import getRethink from '../../database/rethinkDriver' import {RDatum} from '../../database/stricterR' -import Organization from '../../database/types/Organization' import OrganizationUser from '../../database/types/OrganizationUser' import {DataLoaderWorker} from '../../graphql/graphql' +import isValid from '../../graphql/isValid' +import getKysely from '../../postgres/getKysely' import insertOrgUserAudit from '../../postgres/helpers/insertOrgUserAudit' import {OrganizationUserAuditEventTypeEnum} from '../../postgres/queries/generated/insertOrgUserAuditQuery' import {getUserById} from '../../postgres/queries/getUsersByIds' @@ -22,7 +23,7 @@ const maybeUpdateOrganizationActiveDomain = async ( dataLoader: DataLoaderWorker ) => { const r = await getRethink() - const organization = await r.table('Organization').get(orgId).run() + const organization = await dataLoader.get('organizations').load(orgId) const {isActiveDomainTouched, activeDomain} = organization // don't modify if the domain was set manually if (isActiveDomainTouched) return @@ -38,14 +39,18 @@ const maybeUpdateOrganizationActiveDomain = async ( // don't modify if we can't guess the domain or the domain we guess is the current domain const domain = await getActiveDomainForOrgId(orgId) if (!domain || domain === activeDomain) return - - await r - .table('Organization') - .get(orgId) - .update({ - activeDomain: domain - }) - .run() + organization.activeDomain = domain + const pg = getKysely() + await Promise.all([ + pg.updateTable('Organization').set({activeDomain: domain}).where('id', '=', orgId).execute(), + r + .table('Organization') + .get(orgId) + .update({ + activeDomain: domain + }) + .run() + ]) } const changePause = (inactive: boolean) => async (_orgIds: string[], user: IUser) => { @@ -76,18 +81,16 @@ const changePause = (inactive: boolean) => async (_orgIds: string[], user: IUser const addUser = async (orgIds: string[], user: IUser, dataLoader: DataLoaderWorker) => { const {id: userId} = user const r = await getRethink() - const {organizations, organizationUsers} = await r({ - organizationUsers: r + const [rawOrganizations, organizationUsers] = await Promise.all([ + dataLoader.get('organizations').loadMany(orgIds), + r .table('OrganizationUser') .getAll(userId, {index: 'userId'}) .orderBy(r.desc('newUserUntil')) - .coerceTo('array') as unknown as OrganizationUser[], - organizations: r - .table('Organization') - .getAll(r.args(orgIds)) - .coerceTo('array') as unknown as Organization[] - }).run() - + .coerceTo('array') + .run() + ]) + const organizations = rawOrganizations.filter(isValid) const docs = orgIds.map((orgId) => { const oldOrganizationUser = organizationUsers.find( (organizationUser) => organizationUser.orgId === orgId @@ -153,7 +156,6 @@ export default async function adjustUserCount( type: InvoiceItemType, dataLoader: DataLoaderWorker ) { - const r = await getRethink() const orgIds = Array.isArray(orgInput) ? orgInput : [orgInput] const user = (await getUserById(userId))! @@ -164,11 +166,8 @@ export default async function adjustUserCount( const auditEventType = auditEventTypeLookup[type] await insertOrgUserAudit(orgIds, userId, auditEventType) - const paidOrgs = await r - .table('Organization') - .getAll(r.args(orgIds), {index: 'id'}) - .filter((org: RDatum) => org('stripeSubscriptionId').default(null).ne(null)) - .run() + const organizations = await dataLoader.get('organizations').loadMany(orgIds) + const paidOrgs = organizations.filter(isValid).filter((org) => org.stripeSubscriptionId) handleEnterpriseOrgQuantityChanges(paidOrgs, dataLoader).catch() handleTeamOrgQuantityChanges(paidOrgs).catch(Logger.error) diff --git a/packages/server/billing/helpers/generateInvoice.ts b/packages/server/billing/helpers/generateInvoice.ts index 3785a7d6303..1b75d4a6537 100644 --- a/packages/server/billing/helpers/generateInvoice.ts +++ b/packages/server/billing/helpers/generateInvoice.ts @@ -8,7 +8,6 @@ import {InvoiceLineItemEnum} from '../../database/types/InvoiceLineItem' import InvoiceLineItemDetail from '../../database/types/InvoiceLineItemDetail' import InvoiceLineItemOtherAdjustments from '../../database/types/InvoiceLineItemOtherAdjustments' import NextPeriodCharges from '../../database/types/NextPeriodCharges' -import Organization from '../../database/types/Organization' import QuantityChangeLineItem from '../../database/types/QuantityChangeLineItem' import generateUID from '../../generateUID' import {DataLoaderWorker} from '../../graphql/graphql' @@ -354,16 +353,16 @@ export default async function generateInvoice( invoice.status === 'paid' && invoice.status_transitions.paid_at ? fromEpochSeconds(invoice.status_transitions.paid_at) : undefined - - const {organization, billingLeaderIds} = await r({ - organization: r.table('Organization').get(orgId) as unknown as Organization, - billingLeaderIds: r + const [organization, billingLeaderIds] = await Promise.all([ + dataLoader.get('organizations').load(orgId), + r .table('OrganizationUser') .getAll(orgId, {index: 'orgId'}) .filter({removedAt: null}) .filter((row: RDatum) => r.expr(['BILLING_LEADER', 'ORG_ADMIN']).contains(row('role'))) - .coerceTo('array')('userId') as unknown as string[] - }).run() + .coerceTo('array')('userId') + .run() as any as string[] + ]) const billingLeaders = (await dataLoader.get('users').loadMany(billingLeaderIds)).filter(isValid) const billingLeaderEmails = billingLeaders.map((user) => user.email) diff --git a/packages/server/billing/helpers/generateUpcomingInvoice.ts b/packages/server/billing/helpers/generateUpcomingInvoice.ts index 189ab930a18..7980c3871df 100644 --- a/packages/server/billing/helpers/generateUpcomingInvoice.ts +++ b/packages/server/billing/helpers/generateUpcomingInvoice.ts @@ -1,4 +1,3 @@ -import getRethink from '../../database/rethinkDriver' import {DataLoaderWorker} from '../../graphql/graphql' import getUpcomingInvoiceId from '../../utils/getUpcomingInvoiceId' import {getStripeManager} from '../../utils/stripe' @@ -6,9 +5,9 @@ import fetchAllLines from './fetchAllLines' import generateInvoice from './generateInvoice' const generateUpcomingInvoice = async (orgId: string, dataLoader: DataLoaderWorker) => { - const r = await getRethink() const invoiceId = getUpcomingInvoiceId(orgId) - const {stripeId} = await r.table('Organization').get(orgId).pluck('stripeId').run() + const organization = await dataLoader.get('organizations').load(orgId) + const {stripeId} = organization const manager = getStripeManager() const [stripeLineItems, upcomingInvoice] = await Promise.all([ fetchAllLines('upcoming', stripeId), diff --git a/packages/server/billing/helpers/teamLimitsCheck.ts b/packages/server/billing/helpers/teamLimitsCheck.ts index 31103db959b..fff842326db 100644 --- a/packages/server/billing/helpers/teamLimitsCheck.ts +++ b/packages/server/billing/helpers/teamLimitsCheck.ts @@ -80,7 +80,13 @@ export const maybeRemoveRestrictions = async (orgId: string, dataLoader: DataLoa if (!(await isLimitExceeded(orgId))) { const billingLeadersIds = await dataLoader.get('billingLeadersIdsByOrgId').load(orgId) + const pg = getKysely() await Promise.all([ + pg + .updateTable('Organization') + .set({tierLimitExceededAt: null, scheduledLockAt: null, lockedAt: null}) + .where('id', '=', orgId) + .execute(), r .table('Organization') .get(orgId) @@ -129,16 +135,26 @@ export const checkTeamsLimit = async (orgId: string, dataLoader: DataLoaderWorke const now = new Date() const scheduledLockAt = new Date(now.getTime() + ms(`${Threshold.STARTER_TIER_LOCK_AFTER_DAYS}d`)) - - await r - .table('Organization') - .get(orgId) - .update({ - tierLimitExceededAt: now, - scheduledLockAt, - updatedAt: now - }) - .run() + const pg = getKysely() + await Promise.all([ + pg + .updateTable('Organization') + .set({ + tierLimitExceededAt: now, + scheduledLockAt + }) + .where('id', '=', orgId) + .execute(), + r + .table('Organization') + .get(orgId) + .update({ + tierLimitExceededAt: now, + scheduledLockAt, + updatedAt: now + }) + .run() + ]) dataLoader.get('organizations').clear(orgId) const billingLeaders = await getBillingLeadersByOrgId(orgId, dataLoader) diff --git a/packages/server/billing/helpers/terminateSubscription.ts b/packages/server/billing/helpers/terminateSubscription.ts index 0b1c6d3f4c5..1850a614828 100644 --- a/packages/server/billing/helpers/terminateSubscription.ts +++ b/packages/server/billing/helpers/terminateSubscription.ts @@ -1,31 +1,45 @@ import getRethink from '../../database/rethinkDriver' import Organization from '../../database/types/Organization' +import getKysely from '../../postgres/getKysely' import {Logger} from '../../utils/Logger' +import sendToSentry from '../../utils/sendToSentry' import {getStripeManager} from '../../utils/stripe' const terminateSubscription = async (orgId: string) => { const r = await getRethink() + const pg = getKysely() const now = new Date() // flag teams as unpaid - const [rethinkResult] = await Promise.all([ - r({ - organization: r - .table('Organization') - .get(orgId) - .update( - { - // periodEnd should always be redundant, but useful for testing purposes - periodEnd: now, - stripeSubscriptionId: null - }, - {returnChanges: true} - )('changes')(0)('old_val') - .default(null) as unknown as Organization - }).run() + const [pgOrganization, organization] = await Promise.all([ + pg + .with('OldOrg', (qc) => + qc.selectFrom('Organization').select('stripeSubscriptionId').where('id', '=', orgId) + ) + .updateTable('Organization') + .set({periodEnd: now, stripeSubscriptionId: null}) + .where('id', '=', orgId) + .returning((qc) => + qc.selectFrom('OldOrg').select('stripeSubscriptionId').as('stripeSubscriptionId') + ) + .executeTakeFirst(), + r + .table('Organization') + .get(orgId) + .update( + { + // periodEnd should always be redundant, but useful for testing purposes + periodEnd: now, + stripeSubscriptionId: null + }, + {returnChanges: true} + )('changes')(0)('old_val') + .default(null) + .run() as unknown as Organization ]) - const {organization} = rethinkResult const {stripeSubscriptionId} = organization - + if (stripeSubscriptionId !== pgOrganization?.stripeSubscriptionId) { + sendToSentry(new Error(`stripeSubscriptionId mismatch for orgId ${orgId}`)) + } if (stripeSubscriptionId) { const manager = getStripeManager() try { diff --git a/packages/server/database/types/GoogleAnalyzedEntity.ts b/packages/server/database/types/GoogleAnalyzedEntity.ts index e66ce25f7c3..b148d69967e 100644 --- a/packages/server/database/types/GoogleAnalyzedEntity.ts +++ b/packages/server/database/types/GoogleAnalyzedEntity.ts @@ -1,5 +1,3 @@ -import {sql} from 'kysely' - interface Input { lemma?: string name: string @@ -17,8 +15,3 @@ export default class GoogleAnalyzedEntity { this.salience = salience } } - -export const toGoogleAnalyzedEntityPG = (entities: GoogleAnalyzedEntity[]) => - sql< - string[] - >`(select coalesce(array_agg((name, salience, lemma)::"GoogleAnalyzedEntity"), '{}') from json_populate_recordset(null::"GoogleAnalyzedEntity", ${JSON.stringify(entities)}))` diff --git a/packages/server/database/types/processTeamsLimitsJob.ts b/packages/server/database/types/processTeamsLimitsJob.ts index 57beb49c170..562a9698028 100644 --- a/packages/server/database/types/processTeamsLimitsJob.ts +++ b/packages/server/database/types/processTeamsLimitsJob.ts @@ -3,6 +3,7 @@ import sendTeamsLimitEmail from '../../billing/helpers/sendTeamsLimitEmail' import {DataLoaderWorker} from '../../graphql/graphql' import isValid from '../../graphql/isValid' import publishNotification from '../../graphql/public/mutations/helpers/publishNotification' +import getKysely from '../../postgres/getKysely' import NotificationTeamsLimitReminder from './NotificationTeamsLimitReminder' import ScheduledTeamLimitsJob from './ScheduledTeamLimitsJob' @@ -27,7 +28,14 @@ const processTeamsLimitsJob = async (job: ScheduledTeamLimitsJob, dataLoader: Da if (type === 'LOCK_ORGANIZATION') { const now = new Date() - await r.table('Organization').get(orgId).update({lockedAt: now}).run() + await Promise.all([ + getKysely() + .updateTable('Organization') + .set({lockedAt: now}) + .where('id', '=', 'orgId') + .execute(), + r.table('Organization').get(orgId).update({lockedAt: now}).run() + ]) organization.lockedAt = lockedAt } else if (type === 'WARN_ORGANIZATION') { const notificationsToInsert = billingLeadersIds.map((userId) => { diff --git a/packages/server/graphql/mutations/addOrg.ts b/packages/server/graphql/mutations/addOrg.ts index d3c9ca3df03..d6ce8215734 100644 --- a/packages/server/graphql/mutations/addOrg.ts +++ b/packages/server/graphql/mutations/addOrg.ts @@ -62,7 +62,11 @@ export default { const teamId = generateUID() const {email} = viewer await createNewOrg(orgId, orgName, viewerId, email, dataLoader) - await createTeamAndLeader(viewer, {id: teamId, orgId, isOnboardTeam: false, ...newTeam}) + await createTeamAndLeader( + viewer, + {id: teamId, orgId, isOnboardTeam: false, ...newTeam}, + dataLoader + ) const {tms} = authToken // MUTATIVE diff --git a/packages/server/graphql/mutations/addTeam.ts b/packages/server/graphql/mutations/addTeam.ts index 132926cd46c..3b9fb4f5bdb 100644 --- a/packages/server/graphql/mutations/addTeam.ts +++ b/packages/server/graphql/mutations/addTeam.ts @@ -85,7 +85,7 @@ export default { // RESOLUTION const teamId = generateUID() - await createTeamAndLeader(viewer, {id: teamId, isOnboardTeam: false, ...newTeam}) + await createTeamAndLeader(viewer, {id: teamId, isOnboardTeam: false, ...newTeam}, dataLoader) const {tms} = authToken // MUTATIVE diff --git a/packages/server/graphql/mutations/createReflection.ts b/packages/server/graphql/mutations/createReflection.ts index 43b55797825..de7171d3705 100644 --- a/packages/server/graphql/mutations/createReflection.ts +++ b/packages/server/graphql/mutations/createReflection.ts @@ -6,10 +6,10 @@ import getGroupSmartTitle from 'parabol-client/utils/smartGroup/getGroupSmartTit import unlockAllStagesForPhase from 'parabol-client/utils/unlockAllStagesForPhase' import normalizeRawDraftJS from 'parabol-client/validation/normalizeRawDraftJS' import getRethink from '../../database/rethinkDriver' -import {toGoogleAnalyzedEntityPG} from '../../database/types/GoogleAnalyzedEntity' import ReflectionGroup from '../../database/types/ReflectionGroup' import generateUID from '../../generateUID' import getKysely from '../../postgres/getKysely' +import {toGoogleAnalyzedEntity} from '../../postgres/helpers/toGoogleAnalyzedEntity' import {analytics} from '../../utils/analytics/analytics' import {getUserId} from '../../utils/authorization' import publish from '../../utils/publish' @@ -102,7 +102,7 @@ export default { await pg .with('Group', (qc) => qc.insertInto('RetroReflectionGroup').values(reflectionGroup)) .insertInto('RetroReflection') - .values({...reflection, entities: toGoogleAnalyzedEntityPG(entities)}) + .values({...reflection, entities: toGoogleAnalyzedEntity(entities)}) .execute() const groupPhase = phases.find((phase) => phase.phaseType === 'group')! diff --git a/packages/server/graphql/mutations/downgradeToStarter.ts b/packages/server/graphql/mutations/downgradeToStarter.ts index 90f948e3d0c..bbed44fd08c 100644 --- a/packages/server/graphql/mutations/downgradeToStarter.ts +++ b/packages/server/graphql/mutations/downgradeToStarter.ts @@ -1,6 +1,5 @@ import {GraphQLID, GraphQLList, GraphQLNonNull, GraphQLString} from 'graphql' import {SubscriptionChannel} from 'parabol-client/types/constEnums' -import getRethink from '../../database/rethinkDriver' import {getUserId, isSuperUser, isUserBillingLeader} from '../../utils/authorization' import publish from '../../utils/publish' import standardError from '../../utils/standardError' @@ -37,7 +36,6 @@ export default { }: {orgId: string; reasonsForLeaving?: TReasonToDowngradeEnum[]; otherTool?: string}, {authToken, dataLoader, socketId: mutatorId}: GQLContext ) { - const r = await getRethink() const operationId = dataLoader.share() const subOptions = {mutatorId, operationId} @@ -56,7 +54,8 @@ export default { return standardError(new Error('Other tool name is too long'), {userId: viewerId}) } - const {stripeSubscriptionId, tier} = await r.table('Organization').get(orgId).run() + const {stripeSubscriptionId, tier} = await dataLoader.get('organizations').load(orgId) + dataLoader.get('organizations').clear(orgId) if (tier === 'starter') { return standardError(new Error('Already on free tier'), {userId: viewerId}) @@ -68,6 +67,7 @@ export default { orgId, stripeSubscriptionId!, viewer, + dataLoader, reasonsForLeaving, otherTool ) diff --git a/packages/server/graphql/mutations/helpers/bootstrapNewUser.ts b/packages/server/graphql/mutations/helpers/bootstrapNewUser.ts index 9fb461504f6..8d1d09d3cbd 100644 --- a/packages/server/graphql/mutations/helpers/bootstrapNewUser.ts +++ b/packages/server/graphql/mutations/helpers/bootstrapNewUser.ts @@ -128,7 +128,7 @@ const bootstrapNewUser = async ( const orgName = `${newUser.preferredName}’s Org` await createNewOrg(orgId, orgName, userId, email, dataLoader) await Promise.all([ - createTeamAndLeader(newUser as IUser, validNewTeam), + createTeamAndLeader(newUser as IUser, validNewTeam, dataLoader), addSeedTasks(userId, teamId), r.table('SuggestedAction').insert(new SuggestedActionInviteYourTeam({userId, teamId})).run(), sendPromptToJoinOrg(newUser, dataLoader) diff --git a/packages/server/graphql/mutations/helpers/createNewOrg.ts b/packages/server/graphql/mutations/helpers/createNewOrg.ts index 26eee3a436c..b5fa95ea827 100644 --- a/packages/server/graphql/mutations/helpers/createNewOrg.ts +++ b/packages/server/graphql/mutations/helpers/createNewOrg.ts @@ -1,6 +1,7 @@ import getRethink from '../../../database/rethinkDriver' import Organization from '../../../database/types/Organization' import OrganizationUser from '../../../database/types/OrganizationUser' +import getKysely from '../../../postgres/getKysely' import insertOrgUserAudit from '../../../postgres/helpers/insertOrgUserAudit' import getDomainFromEmail from '../../../utils/getDomainFromEmail' import {DataLoaderWorker} from '../../graphql' @@ -28,6 +29,10 @@ export default async function createNewOrg( tier: org.tier }) await insertOrgUserAudit([orgId], leaderUserId, 'added') + await getKysely() + .insertInto('Organization') + .values({...org, creditCard: null}) + .execute() return r({ org: r.table('Organization').insert(org), organizationUser: r.table('OrganizationUser').insert(orgUser) diff --git a/packages/server/graphql/mutations/helpers/createTeamAndLeader.ts b/packages/server/graphql/mutations/helpers/createTeamAndLeader.ts index f76a2540d27..1019dd0b789 100644 --- a/packages/server/graphql/mutations/helpers/createTeamAndLeader.ts +++ b/packages/server/graphql/mutations/helpers/createTeamAndLeader.ts @@ -4,6 +4,7 @@ import MeetingSettingsPoker from '../../../database/types/MeetingSettingsPoker' import MeetingSettingsRetrospective from '../../../database/types/MeetingSettingsRetrospective' import Team from '../../../database/types/Team' import TimelineEventCreatedTeam from '../../../database/types/TimelineEventCreatedTeam' +import {DataLoaderInstance} from '../../../dataloader/RootDataLoader' import getKysely from '../../../postgres/getKysely' import IUser from '../../../postgres/types/IUser' import addTeamIdToTMS from '../../../safeMutations/addTeamIdToTMS' @@ -17,11 +18,15 @@ interface ValidNewTeam { } // used for addorg, addTeam -export default async function createTeamAndLeader(user: IUser, newTeam: ValidNewTeam) { +export default async function createTeamAndLeader( + user: IUser, + newTeam: ValidNewTeam, + dataLoader: DataLoaderInstance +) { const r = await getRethink() const {id: userId} = user const {id: teamId, orgId} = newTeam - const organization = await r.table('Organization').get(orgId).run() + const organization = await dataLoader.get('organizations').load(orgId) const {tier, trialStartDate} = organization const verifiedTeam = new Team({...newTeam, createdBy: userId, tier, trialStartDate}) const meetingSettings = [ diff --git a/packages/server/graphql/mutations/helpers/hideConversionModal.ts b/packages/server/graphql/mutations/helpers/hideConversionModal.ts index c71115516d5..3e186459c44 100644 --- a/packages/server/graphql/mutations/helpers/hideConversionModal.ts +++ b/packages/server/graphql/mutations/helpers/hideConversionModal.ts @@ -1,4 +1,5 @@ import getRethink from '../../../database/rethinkDriver' +import getKysely from '../../../postgres/getKysely' import errorFilter from '../../errorFilter' import {DataLoaderWorker} from '../../graphql' @@ -7,6 +8,11 @@ const hideConversionModal = async (orgId: string, dataLoader: DataLoaderWorker) const {showConversionModal} = organization if (showConversionModal) { const r = await getRethink() + await getKysely() + .updateTable('Organization') + .set({showConversionModal: false}) + .where('id', '=', orgId) + .execute() await r .table('Organization') .get(orgId) diff --git a/packages/server/graphql/mutations/helpers/removeFromOrg.ts b/packages/server/graphql/mutations/helpers/removeFromOrg.ts index 945fc3d4a83..ac4fb4ccca5 100644 --- a/packages/server/graphql/mutations/helpers/removeFromOrg.ts +++ b/packages/server/graphql/mutations/helpers/removeFromOrg.ts @@ -57,7 +57,7 @@ const removeFromOrg = async ( // need to make sure the org doc is updated before adjusting this const {role} = organizationUser if (role && ['BILLING_LEADER', 'ORG_ADMIN'].includes(role)) { - const organization = await r.table('Organization').get(orgId).run() + const organization = await dataLoader.get('organizations').load(orgId) // if no other billing leader, promote the oldest // if team tier & no other member, downgrade to starter const otherBillingLeaders = await r @@ -84,7 +84,7 @@ const removeFromOrg = async ( }) .run() } else if (organization.tier !== 'starter') { - await resolveDowngradeToStarter(orgId, organization.stripeSubscriptionId!, user) + await resolveDowngradeToStarter(orgId, organization.stripeSubscriptionId!, user, dataLoader) } } } diff --git a/packages/server/graphql/mutations/helpers/resolveDowngradeToStarter.ts b/packages/server/graphql/mutations/helpers/resolveDowngradeToStarter.ts index ce82907ecfb..803c767e1e7 100644 --- a/packages/server/graphql/mutations/helpers/resolveDowngradeToStarter.ts +++ b/packages/server/graphql/mutations/helpers/resolveDowngradeToStarter.ts @@ -1,5 +1,5 @@ import getRethink from '../../../database/rethinkDriver' -import Organization from '../../../database/types/Organization' +import {DataLoaderInstance} from '../../../dataloader/RootDataLoader' import getKysely from '../../../postgres/getKysely' import updateTeamByOrgId from '../../../postgres/queries/updateTeamByOrgId' import {analytics} from '../../../utils/analytics/analytics' @@ -13,6 +13,7 @@ const resolveDowngradeToStarter = async ( orgId: string, stripeSubscriptionId: string, user: {id: string; email: string}, + dataLoader: DataLoaderInstance, reasonsForLeaving?: ReasonToDowngradeEnum[], otherTool?: string ) => { @@ -27,7 +28,16 @@ const resolveDowngradeToStarter = async ( } const [org] = await Promise.all([ - r.table('Organization').get(orgId).run() as unknown as Organization, + dataLoader.get('organizations').load(orgId), + pg + .updateTable('Organization') + .set({ + tier: 'starter', + periodEnd: now, + stripeSubscriptionId: null + }) + .where('id', '=', orgId) + .execute(), pg .updateTable('SAML') .set({metadata: null, lastUpdatedBy: user.id}) @@ -49,7 +59,7 @@ const resolveDowngradeToStarter = async ( orgId ) ]) - + dataLoader.get('organizations').clear(orgId) await Promise.all([setUserTierForOrgId(orgId), setTierForOrgUsers(orgId)]) analytics.organizationDowngraded(user, { orgId, diff --git a/packages/server/graphql/mutations/helpers/safeCreateRetrospective.ts b/packages/server/graphql/mutations/helpers/safeCreateRetrospective.ts index 7e49609a4df..c803811cc56 100644 --- a/packages/server/graphql/mutations/helpers/safeCreateRetrospective.ts +++ b/packages/server/graphql/mutations/helpers/safeCreateRetrospective.ts @@ -34,7 +34,7 @@ const safeCreateRetrospective = async ( dataLoader.get('teams').loadNonNull(teamId) ]) - const organization = await r.table('Organization').get(team.orgId).run() + const organization = await dataLoader.get('organizations').load(team.orgId) const {showConversionModal} = organization const meetingId = generateUID() diff --git a/packages/server/graphql/mutations/moveTeamToOrg.ts b/packages/server/graphql/mutations/moveTeamToOrg.ts index 18196676f41..bef33207dd1 100644 --- a/packages/server/graphql/mutations/moveTeamToOrg.ts +++ b/packages/server/graphql/mutations/moveTeamToOrg.ts @@ -30,7 +30,7 @@ const moveToOrg = async ( const su = isSuperUser(authToken) // VALIDATION const [org, teams, isPaidResult] = await Promise.all([ - r.table('Organization').get(orgId).run(), + dataLoader.get('organizations').load(orgId), getTeamsByIds([teamId]), pg .selectFrom('Team') @@ -117,7 +117,7 @@ const moveToOrg = async ( const {newToOrgUserIds} = rethinkResult // if no teams remain on the org, remove it - await safeArchiveEmptyStarterOrganization(currentOrgId) + await safeArchiveEmptyStarterOrganization(currentOrgId, dataLoader) await Promise.all( newToOrgUserIds.map((newUserId) => { diff --git a/packages/server/graphql/mutations/payLater.ts b/packages/server/graphql/mutations/payLater.ts index 54af091b0d2..49a4b6ae794 100644 --- a/packages/server/graphql/mutations/payLater.ts +++ b/packages/server/graphql/mutations/payLater.ts @@ -2,6 +2,7 @@ import {GraphQLID, GraphQLNonNull} from 'graphql' import {SubscriptionChannel} from 'parabol-client/types/constEnums' import getRethink from '../../database/rethinkDriver' import {RValue} from '../../database/stricterR' +import getKysely from '../../postgres/getKysely' import getPg from '../../postgres/getPg' import {incrementUserPayLaterClickCountQuery} from '../../postgres/queries/generated/incrementUserPayLaterClickCountQuery' import {analytics} from '../../utils/analytics/analytics' @@ -49,6 +50,13 @@ export default { // RESOLUTION const team = await dataLoader.get('teams').loadNonNull(teamId) const {orgId} = team + await getKysely() + .updateTable('Organization') + .set((eb) => ({ + payLaterClickCount: eb('payLaterClickCount', '+', 1) + })) + .where('id', '=', orgId) + .execute() await r .table('Organization') .get(orgId) diff --git a/packages/server/graphql/mutations/updateReflectionContent.ts b/packages/server/graphql/mutations/updateReflectionContent.ts index c716f776884..36b79bfb958 100644 --- a/packages/server/graphql/mutations/updateReflectionContent.ts +++ b/packages/server/graphql/mutations/updateReflectionContent.ts @@ -5,8 +5,8 @@ import isPhaseComplete from 'parabol-client/utils/meetings/isPhaseComplete' import getGroupSmartTitle from 'parabol-client/utils/smartGroup/getGroupSmartTitle' import normalizeRawDraftJS from 'parabol-client/validation/normalizeRawDraftJS' import stringSimilarity from 'string-similarity' -import {toGoogleAnalyzedEntityPG} from '../../database/types/GoogleAnalyzedEntity' import getKysely from '../../postgres/getKysely' +import {toGoogleAnalyzedEntity} from '../../postgres/helpers/toGoogleAnalyzedEntity' import {getUserId, isTeamMember} from '../../utils/authorization' import publish from '../../utils/publish' import standardError from '../../utils/standardError' @@ -88,7 +88,7 @@ export default { .updateTable('RetroReflection') .set({ content: normalizedContent, - entities: toGoogleAnalyzedEntityPG(entities), + entities: toGoogleAnalyzedEntity(entities), sentimentScore, plaintextContent }) diff --git a/packages/server/graphql/private/mutations/changeEmailDomain.ts b/packages/server/graphql/private/mutations/changeEmailDomain.ts index dab120dd709..351bbe85af4 100644 --- a/packages/server/graphql/private/mutations/changeEmailDomain.ts +++ b/packages/server/graphql/private/mutations/changeEmailDomain.ts @@ -55,6 +55,11 @@ const changeEmailDomain: MutationResolvers['changeEmailDomain'] = async ( }) .where('domain', 'like', normalizedOldDomain) .execute(), + pg + .updateTable('Organization') + .set({activeDomain: normalizedNewDomain}) + .where('activeDomain', '=', normalizedOldDomain) + .execute(), r .table('Organization') .filter((row: RDatum) => row('activeDomain').eq(normalizedOldDomain)) diff --git a/packages/server/graphql/private/mutations/draftEnterpriseInvoice.ts b/packages/server/graphql/private/mutations/draftEnterpriseInvoice.ts index aa0ea60f306..7f47be534ad 100644 --- a/packages/server/graphql/private/mutations/draftEnterpriseInvoice.ts +++ b/packages/server/graphql/private/mutations/draftEnterpriseInvoice.ts @@ -101,6 +101,11 @@ const draftEnterpriseInvoice: MutationResolvers['draftEnterpriseInvoice'] = asyn if (!stripeId) { // create the customer const customer = await manager.createCustomer(orgId, apEmail || user.email) + await getKysely() + .updateTable('Organization') + .set({stripeId: customer.id}) + .where('id', '=', orgId) + .execute() await r.table('Organization').get(orgId).update({stripeId: customer.id}).run() customerId = customer.id } else { @@ -115,6 +120,21 @@ const draftEnterpriseInvoice: MutationResolvers['draftEnterpriseInvoice'] = asyn ) await Promise.all([ + pg + .updateTable('Organization') + .set({ + periodEnd: fromEpochSeconds(subscription.current_period_end), + periodStart: fromEpochSeconds(subscription.current_period_start), + stripeSubscriptionId: subscription.id, + tier: 'enterprise', + tierLimitExceededAt: null, + scheduledLockAt: null, + lockedAt: null, + updatedAt: now, + trialStartDate: null + }) + .where('id', '=', orgId) + .execute(), r({ updatedOrg: r .table('Organization') diff --git a/packages/server/graphql/private/mutations/endTrial.ts b/packages/server/graphql/private/mutations/endTrial.ts index fce952a0eaa..890ffdb0015 100644 --- a/packages/server/graphql/private/mutations/endTrial.ts +++ b/packages/server/graphql/private/mutations/endTrial.ts @@ -19,6 +19,7 @@ const endTrial: MutationResolvers['endTrial'] = async (_source, {orgId}, {dataLo // RESOLUTION await Promise.all([ + pg.updateTable('Organization').set({trialStartDate: null}).where('id', '=', orgId).execute(), r({ orgUpdate: r.table('Organization').get(orgId).update({ trialStartDate: null, diff --git a/packages/server/graphql/private/mutations/flagConversionModal.ts b/packages/server/graphql/private/mutations/flagConversionModal.ts index b46548d4e92..4ba7bd4b4f1 100644 --- a/packages/server/graphql/private/mutations/flagConversionModal.ts +++ b/packages/server/graphql/private/mutations/flagConversionModal.ts @@ -1,19 +1,27 @@ import getRethink from '../../../database/rethinkDriver' +import getKysely from '../../../postgres/getKysely' import {MutationResolvers} from '../resolverTypes' const flagConversionModal: MutationResolvers['flagConversionModal'] = async ( _source, - {active, orgId} + {active, orgId}, + {dataLoader} ) => { const r = await getRethink() // VALIDATION - const organization = await r.table('Organization').get(orgId).run() + const organization = await dataLoader.get('organizations').load(orgId) if (!organization) { return {error: {message: 'Invalid orgId'}} } // RESOLUTION + organization.showConversionModal = active + await getKysely() + .updateTable('Organization') + .set({showConversionModal: active}) + .where('id', '=', orgId) + .execute() await r .table('Organization') .get(orgId) diff --git a/packages/server/graphql/private/mutations/sendUpcomingInvoiceEmails.ts b/packages/server/graphql/private/mutations/sendUpcomingInvoiceEmails.ts index 01642c464df..d49383b0486 100644 --- a/packages/server/graphql/private/mutations/sendUpcomingInvoiceEmails.ts +++ b/packages/server/graphql/private/mutations/sendUpcomingInvoiceEmails.ts @@ -8,6 +8,7 @@ import getRethink from '../../../database/rethinkDriver' import {RDatum, RValue} from '../../../database/stricterR' import UpcomingInvoiceEmailTemplate from '../../../email/UpcomingInvoiceEmailTemplate' import getMailManager from '../../../email/getMailManager' +import getKysely from '../../../postgres/getKysely' import IUser from '../../../postgres/types/IUser' import {MutationResolvers} from '../resolverTypes' @@ -134,6 +135,11 @@ const sendUpcomingInvoiceEmails: MutationResolvers['sendUpcomingInvoiceEmails'] }) ) const orgIds = organizations.map(({id}) => id) + await getKysely() + .updateTable('Organization') + .set({upcomingInvoiceEmailSentAt: now}) + .where('id', 'in', orgIds) + .execute() await r .table('Organization') .getAll(r.args(orgIds)) diff --git a/packages/server/graphql/private/mutations/setOrganizationDomain.ts b/packages/server/graphql/private/mutations/setOrganizationDomain.ts index cefc6fa5274..b374ed7ce35 100644 --- a/packages/server/graphql/private/mutations/setOrganizationDomain.ts +++ b/packages/server/graphql/private/mutations/setOrganizationDomain.ts @@ -1,19 +1,26 @@ import getRethink from '../../../database/rethinkDriver' +import getKysely from '../../../postgres/getKysely' import {MutationResolvers} from '../resolverTypes' const setOrganizationDomain: MutationResolvers['setOrganizationDomain'] = async ( _source, - {orgId, domain} + {orgId, domain}, + {dataLoader} ) => { const r = await getRethink() // VALIDATION - const organization = await r.table('Organization').get(orgId).run() - + const organization = await dataLoader.get('organizations').load(orgId) + dataLoader.get('organizations').clear(orgId) if (!organization) { throw new Error('Organization not found') } // RESOLUTION + await getKysely() + .updateTable('Organization') + .set({activeDomain: domain, isActiveDomainTouched: true}) + .where('id', '=', orgId) + .execute() await r .table('Organization') .get(orgId) diff --git a/packages/server/graphql/private/mutations/startTrial.ts b/packages/server/graphql/private/mutations/startTrial.ts index af4e52efb61..cff3800cb7b 100644 --- a/packages/server/graphql/private/mutations/startTrial.ts +++ b/packages/server/graphql/private/mutations/startTrial.ts @@ -25,6 +25,11 @@ const startTrial: MutationResolvers['startTrial'] = async (_source, {orgId}, {da // RESOLUTION await Promise.all([ + pg + .updateTable('Organization') + .set({trialStartDate: now, tierLimitExceededAt: null, scheduledLockAt: null, lockedAt: null}) + .where('id', '=', orgId) + .execute(), r({ updatedOrg: r.table('Organization').get(orgId).update({ trialStartDate: now, diff --git a/packages/server/graphql/private/mutations/stripeDeleteSubscription.ts b/packages/server/graphql/private/mutations/stripeDeleteSubscription.ts index 960bb1a01f2..7c1a9b604d6 100644 --- a/packages/server/graphql/private/mutations/stripeDeleteSubscription.ts +++ b/packages/server/graphql/private/mutations/stripeDeleteSubscription.ts @@ -1,5 +1,6 @@ import getRethink from '../../../database/rethinkDriver' import Organization from '../../../database/types/Organization' +import getKysely from '../../../postgres/getKysely' import {isSuperUser} from '../../../utils/authorization' import {getStripeManager} from '../../../utils/stripe' import {MutationResolvers} from '../resolverTypes' @@ -34,7 +35,11 @@ const stripeDeleteSubscription: MutationResolvers['stripeDeleteSubscription'] = if (stripeSubscriptionId !== subscriptionId) { throw new Error('Subscription ID does not match') } - + await getKysely() + .updateTable('Organization') + .set({stripeSubscriptionId: null}) + .where('id', '=', orgId) + .execute() await r .table('Organization') .get(orgId) diff --git a/packages/server/graphql/private/mutations/stripeInvoiceFinalized.ts b/packages/server/graphql/private/mutations/stripeInvoiceFinalized.ts index 480a6bfef12..6450f9dc57f 100644 --- a/packages/server/graphql/private/mutations/stripeInvoiceFinalized.ts +++ b/packages/server/graphql/private/mutations/stripeInvoiceFinalized.ts @@ -6,7 +6,7 @@ import {MutationResolvers} from '../resolverTypes' const stripeInvoiceFinalized: MutationResolvers['stripeInvoiceFinalized'] = async ( _source, {invoiceId}, - {authToken} + {authToken, dataLoader} ) => { const r = await getRethink() const now = new Date() @@ -29,7 +29,7 @@ const stripeInvoiceFinalized: MutationResolvers['stripeInvoiceFinalized'] = asyn livemode, metadata: {orgId} } = customer - const org = await r.table('Organization').get(orgId).run() + const org = await dataLoader.get('organizations').load(orgId!) if (!org) { if (livemode) { throw new Error( diff --git a/packages/server/graphql/private/mutations/stripeInvoicePaid.ts b/packages/server/graphql/private/mutations/stripeInvoicePaid.ts index 67956661c62..527dd1ec1fa 100644 --- a/packages/server/graphql/private/mutations/stripeInvoicePaid.ts +++ b/packages/server/graphql/private/mutations/stripeInvoicePaid.ts @@ -7,7 +7,7 @@ import {MutationResolvers} from '../resolverTypes' const stripeInvoicePaid: MutationResolvers['stripeInvoicePaid'] = async ( _source, {invoiceId}, - {authToken} + {authToken, dataLoader} ) => { const r = await getRethink() const now = new Date() @@ -30,8 +30,11 @@ const stripeInvoicePaid: MutationResolvers['stripeInvoicePaid'] = async ( livemode, metadata: {orgId} } = stripeCustomer - const org = await r.table('Organization').get(orgId).run() - if (!org || !orgId) { + if (!orgId) { + throw new Error(`Payment cannot succeed. Org ${orgId} does not exist for invoice ${invoiceId}`) + } + const org = await dataLoader.get('organizations').load(orgId) + if (!org) { if (livemode) { throw new Error( `Payment cannot succeed. Org ${orgId} does not exist for invoice ${invoiceId}` diff --git a/packages/server/graphql/private/mutations/stripeSucceedPayment.ts b/packages/server/graphql/private/mutations/stripeSucceedPayment.ts index 30334a7dc7e..03a62264dae 100644 --- a/packages/server/graphql/private/mutations/stripeSucceedPayment.ts +++ b/packages/server/graphql/private/mutations/stripeSucceedPayment.ts @@ -7,7 +7,7 @@ import {MutationResolvers} from '../resolverTypes' const stripeSucceedPayment: MutationResolvers['stripeSucceedPayment'] = async ( _source, {invoiceId}, - {authToken} + {authToken, dataLoader} ) => { const r = await getRethink() const now = new Date() @@ -30,8 +30,11 @@ const stripeSucceedPayment: MutationResolvers['stripeSucceedPayment'] = async ( livemode, metadata: {orgId} } = customer - const org = await r.table('Organization').get(orgId).run() - if (!org || !orgId) { + if (!orgId) { + throw new Error(`Payment cannot succeed. Org ${orgId} does not exist for invoice ${invoiceId}`) + } + const org = await dataLoader.get('organizations').load(orgId) + if (!org) { if (livemode) { throw new Error( `Payment cannot succeed. Org ${orgId} does not exist for invoice ${invoiceId}` diff --git a/packages/server/graphql/private/mutations/stripeUpdateCreditCard.ts b/packages/server/graphql/private/mutations/stripeUpdateCreditCard.ts index 3fc305757a1..2772c922b30 100644 --- a/packages/server/graphql/private/mutations/stripeUpdateCreditCard.ts +++ b/packages/server/graphql/private/mutations/stripeUpdateCreditCard.ts @@ -1,4 +1,6 @@ import getRethink from '../../../database/rethinkDriver' +import getKysely from '../../../postgres/getKysely' +import {toCreditCard} from '../../../postgres/helpers/toCreditCard' import {isSuperUser} from '../../../utils/authorization' import {getStripeManager} from '../../../utils/stripe' import getCCFromCustomer from '../../mutations/helpers/getCCFromCustomer' @@ -23,6 +25,14 @@ const stripeUpdateCreditCard: MutationResolvers['stripeUpdateCreditCard'] = asyn const { metadata: {orgId} } = customer + if (!orgId) { + throw new Error('Unable to update credit card as customer does not have an orgId') + } + await getKysely() + .updateTable('Organization') + .set({creditCard: toCreditCard(creditCard)}) + .where('id', '=', orgId) + .execute() await r.table('Organization').get(orgId).update({creditCard}).run() return true } diff --git a/packages/server/graphql/private/mutations/stripeUpdateSubscription.ts b/packages/server/graphql/private/mutations/stripeUpdateSubscription.ts index 12831a9fab4..aa7d78707ae 100644 --- a/packages/server/graphql/private/mutations/stripeUpdateSubscription.ts +++ b/packages/server/graphql/private/mutations/stripeUpdateSubscription.ts @@ -1,4 +1,5 @@ import getRethink from '../../../database/rethinkDriver' +import getKysely from '../../../postgres/getKysely' import {isSuperUser} from '../../../utils/authorization' import {getStripeManager} from '../../../utils/stripe' import {MutationResolvers} from '../resolverTypes' @@ -28,6 +29,13 @@ const stripeUpdateSubscription: MutationResolvers['stripeUpdateSubscription'] = throw new Error(`orgId not found on metadata for customer ${customerId}`) } + await getKysely() + .updateTable('Organization') + .set({ + stripeSubscriptionId: subscriptionId + }) + .where('id', '=', orgId) + .execute() await r .table('Organization') .get(orgId) diff --git a/packages/server/graphql/private/mutations/updateOrgFeatureFlag.ts b/packages/server/graphql/private/mutations/updateOrgFeatureFlag.ts index 86cede3f0b7..98ae9bab56a 100644 --- a/packages/server/graphql/private/mutations/updateOrgFeatureFlag.ts +++ b/packages/server/graphql/private/mutations/updateOrgFeatureFlag.ts @@ -1,14 +1,18 @@ +import {sql} from 'kysely' import {RValue} from 'rethinkdb-ts' import getRethink from '../../../database/rethinkDriver' +import getKysely from '../../../postgres/getKysely' +import isValid from '../../isValid' import {MutationResolvers} from '../resolverTypes' const updateOrgFeatureFlag: MutationResolvers['updateOrgFeatureFlag'] = async ( _source, - {orgIds, flag, addFlag} + {orgIds, flag, addFlag}, + {dataLoader} ) => { const r = await getRethink() - - const existingIds = (await r.table('Organization').getAll(r.args(orgIds))('id').run()) as string[] + const existingOrgs = (await dataLoader.get('organizations').loadMany(orgIds)).filter(isValid) + const existingIds = existingOrgs.map(({id}) => id) const nonExistingIds = orgIds.filter((x) => !existingIds.includes(x)) @@ -17,6 +21,20 @@ const updateOrgFeatureFlag: MutationResolvers['updateOrgFeatureFlag'] = async ( } // RESOLUTION + await getKysely() + .updateTable('Organization') + .$if(addFlag, (db) => db.set({featureFlags: sql`ARRAY_APPEND("featureFlags",${flag})`})) + .$if(!addFlag, (db) => + db.set({ + featureFlags: sql`array_cat( + "featureFlags"[1:array_position("featureFlags",${flag})-1], + "featureFlags"[array_position("featureFlags",${flag})+1:] + )` + }) + ) + .where('id', 'in', orgIds) + .returning('id') + .execute() const updatedOrgIds = (await r .table('Organization') .getAll(r.args(orgIds)) diff --git a/packages/server/graphql/private/mutations/upgradeToTeamTier.ts b/packages/server/graphql/private/mutations/upgradeToTeamTier.ts index 38efacb4caf..e89676ba606 100644 --- a/packages/server/graphql/private/mutations/upgradeToTeamTier.ts +++ b/packages/server/graphql/private/mutations/upgradeToTeamTier.ts @@ -2,6 +2,7 @@ import {SubscriptionChannel} from 'parabol-client/types/constEnums' import removeTeamsLimitObjects from '../../../billing/helpers/removeTeamsLimitObjects' import getRethink from '../../../database/rethinkDriver' import getKysely from '../../../postgres/getKysely' +import {toCreditCard} from '../../../postgres/helpers/toCreditCard' import {analytics} from '../../../utils/analytics/analytics' import {getUserId} from '../../../utils/authorization' import publish from '../../../utils/publish' @@ -81,20 +82,30 @@ const upgradeToTeamTier: MutationResolvers['upgradeToTeamTier'] = async ( } // RESOLUTION + const creditCard = await getCCFromCustomer(customer) await Promise.all([ + pg + .updateTable('Organization') + .set({ + creditCard: toCreditCard(creditCard), + tier: 'team', + tierLimitExceededAt: null, + scheduledLockAt: null, + lockedAt: null, + trialStartDate: null + }) + .where('id', '=', orgId) + .execute(), r({ - updatedOrg: r - .table('Organization') - .get(orgId) - .update({ - creditCard: await getCCFromCustomer(customer), - tier: 'team', - tierLimitExceededAt: null, - scheduledLockAt: null, - lockedAt: null, - updatedAt: now, - trialStartDate: null - }) + updatedOrg: r.table('Organization').get(orgId).update({ + creditCard, + tier: 'team', + tierLimitExceededAt: null, + scheduledLockAt: null, + lockedAt: null, + updatedAt: now, + trialStartDate: null + }) }).run(), pg .updateTable('Team') diff --git a/packages/server/graphql/public/mutations/acceptRequestToJoinDomain.ts b/packages/server/graphql/public/mutations/acceptRequestToJoinDomain.ts index db1ea25044e..324c4404ecd 100644 --- a/packages/server/graphql/public/mutations/acceptRequestToJoinDomain.ts +++ b/packages/server/graphql/public/mutations/acceptRequestToJoinDomain.ts @@ -56,11 +56,10 @@ const acceptRequestToJoinDomain: MutationResolvers['acceptRequestToJoinDomain'] // Provided request domain should match team's organizations activeDomain const leadTeams = await getTeamsByIds(validTeamMembers.map((teamMember) => teamMember.teamId)) - const validOrgIds = await r - .table('Organization') - .getAll(r.args(leadTeams.map((team) => team.orgId))) - .filter({activeDomain: domain})('id') - .run() + const teamOrgs = await Promise.all( + leadTeams.map((t) => dataLoader.get('organizations').load(t.orgId)) + ) + const validOrgIds = teamOrgs.filter((org) => org.activeDomain === domain).map(({id}) => id) if (!validOrgIds.length) { return standardError(new Error('Invalid organizations')) diff --git a/packages/server/graphql/public/mutations/createStripeSubscription.ts b/packages/server/graphql/public/mutations/createStripeSubscription.ts index 02ceb15c616..dedfb558f9d 100644 --- a/packages/server/graphql/public/mutations/createStripeSubscription.ts +++ b/packages/server/graphql/public/mutations/createStripeSubscription.ts @@ -1,5 +1,6 @@ import Stripe from 'stripe' import getRethink from '../../../database/rethinkDriver' +import getKysely from '../../../postgres/getKysely' import {getUserId} from '../../../utils/authorization' import {fromEpochSeconds} from '../../../utils/epochTime' import standardError from '../../../utils/standardError' @@ -58,6 +59,15 @@ const createStripeSubscription: MutationResolvers['createStripeSubscription'] = stripeSubscriptionId: subscription.id } + await getKysely() + .updateTable('Organization') + .set({ + ...subscriptionFields, + stripeId: customer.id + }) + .where('id', '=', orgId) + .execute() + await r({ updatedOrg: r .table('Organization') diff --git a/packages/server/graphql/public/mutations/updateCreditCard.ts b/packages/server/graphql/public/mutations/updateCreditCard.ts index d607f16cbe0..984c9758e17 100644 --- a/packages/server/graphql/public/mutations/updateCreditCard.ts +++ b/packages/server/graphql/public/mutations/updateCreditCard.ts @@ -2,6 +2,8 @@ import {SubscriptionChannel} from 'parabol-client/types/constEnums' import Stripe from 'stripe' import removeTeamsLimitObjects from '../../../billing/helpers/removeTeamsLimitObjects' import getRethink from '../../../database/rethinkDriver' +import getKysely from '../../../postgres/getKysely' +import {toCreditCard} from '../../../postgres/helpers/toCreditCard' import updateTeamByOrgId from '../../../postgres/queries/updateTeamByOrgId' import {getUserId, isUserBillingLeader} from '../../../utils/authorization' import publish from '../../../utils/publish' @@ -56,6 +58,18 @@ const updateCreditCard: MutationResolvers['updateCreditCard'] = async ( const creditCard = stripeCardToDBCard(stripeCard) await Promise.all([ + getKysely() + .updateTable('Organization') + .set({ + creditCard: toCreditCard(creditCard), + tier: 'team', + stripeId: customer.id, + tierLimitExceededAt: null, + scheduledLockAt: null, + lockedAt: null + }) + .where('id', '=', orgId) + .execute(), r({ updatedOrg: r.table('Organization').get(orgId).update({ creditCard, diff --git a/packages/server/graphql/public/mutations/updateOrg.ts b/packages/server/graphql/public/mutations/updateOrg.ts index e0be5066410..3aedaf7d82c 100644 --- a/packages/server/graphql/public/mutations/updateOrg.ts +++ b/packages/server/graphql/public/mutations/updateOrg.ts @@ -1,5 +1,6 @@ import {SubscriptionChannel} from 'parabol-client/types/constEnums' import getRethink from '../../../database/rethinkDriver' +import getKysely from '../../../postgres/getKysely' import {getUserId, isUserBillingLeader} from '../../../utils/authorization' import publish from '../../../utils/publish' import standardError from '../../../utils/standardError' @@ -41,6 +42,11 @@ const updateOrg: MutationResolvers['updateOrg'] = async ( name: normalizedName, updatedAt: now } + await getKysely() + .updateTable('Organization') + .set({name: normalizedName}) + .where('id', '=', orgId) + .execute() await r.table('Organization').get(orgId).update(dbUpdate).run() const data = {orgId} diff --git a/packages/server/graphql/public/mutations/uploadOrgImage.ts b/packages/server/graphql/public/mutations/uploadOrgImage.ts index 22f4812cf9f..e03a52f2ce7 100644 --- a/packages/server/graphql/public/mutations/uploadOrgImage.ts +++ b/packages/server/graphql/public/mutations/uploadOrgImage.ts @@ -3,6 +3,7 @@ import getRethink from '../../../database/rethinkDriver' import getFileStoreManager from '../../../fileStorage/getFileStoreManager' import normalizeAvatarUpload from '../../../fileStorage/normalizeAvatarUpload' import validateAvatarUpload from '../../../fileStorage/validateAvatarUpload' +import getKysely from '../../../postgres/getKysely' import {getUserId, isUserBillingLeader} from '../../../utils/authorization' import publish from '../../../utils/publish' import standardError from '../../../utils/standardError' @@ -33,7 +34,11 @@ const uploadOrgImage: MutationResolvers['uploadOrgImage'] = async ( const [normalExt, normalBuffer] = await normalizeAvatarUpload(validExt, validBuffer) const manager = getFileStoreManager() const publicLocation = await manager.putOrgAvatar(normalBuffer, orgId, normalExt) - + await getKysely() + .updateTable('Organization') + .set({picture: publicLocation}) + .where('id', '=', orgId) + .execute() await r .table('Organization') .get(orgId) diff --git a/packages/server/graphql/public/types/DomainJoinRequest.ts b/packages/server/graphql/public/types/DomainJoinRequest.ts index ffe93556170..0b030ce8d79 100644 --- a/packages/server/graphql/public/types/DomainJoinRequest.ts +++ b/packages/server/graphql/public/types/DomainJoinRequest.ts @@ -31,11 +31,10 @@ const DomainJoinRequest: DomainJoinRequestResolvers = { const leadTeamIds = leadTeamMembers.map((teamMember) => teamMember.teamId) const leadTeams = (await dataLoader.get('teams').loadMany(leadTeamIds)).filter(isValid) - const validOrgIds = await r - .table('Organization') - .getAll(r.args(leadTeams.map((team) => team.orgId))) - .filter({activeDomain: domain})('id') - .run() + const teamOrgs = await Promise.all( + leadTeams.map((t) => dataLoader.get('organizations').load(t.orgId)) + ) + const validOrgIds = teamOrgs.filter((org) => org.activeDomain === domain).map(({id}) => id) const validTeams = leadTeams.filter((team) => validOrgIds.includes(team.orgId)) return validTeams diff --git a/packages/server/graphql/queries/invoices.ts b/packages/server/graphql/queries/invoices.ts index 9454ec627c7..c9e043efd8c 100644 --- a/packages/server/graphql/queries/invoices.ts +++ b/packages/server/graphql/queries/invoices.ts @@ -38,7 +38,7 @@ export default { } // RESOLUTION - const {stripeId} = await r.table('Organization').get(orgId).pluck('stripeId').run() + const {stripeId} = await dataLoader.get('organizations').load(orgId) const dbAfter = after ? new Date(after) : r.maxval const [tooManyInvoices, orgUserCount] = await Promise.all([ r diff --git a/packages/server/postgres/helpers/toCreditCard.ts b/packages/server/postgres/helpers/toCreditCard.ts new file mode 100644 index 00000000000..2f4e4c02b42 --- /dev/null +++ b/packages/server/postgres/helpers/toCreditCard.ts @@ -0,0 +1,6 @@ +import {sql} from 'kysely' +import CreditCard from '../../database/types/CreditCard' +export const toCreditCard = (creditCard: CreditCard | undefined | null) => { + if (!creditCard) return null + return sql`(select json_populate_record(null::"CreditCard", ${JSON.stringify(creditCard)}))` +} diff --git a/packages/server/postgres/helpers/toGoogleAnalyzedEntity.ts b/packages/server/postgres/helpers/toGoogleAnalyzedEntity.ts new file mode 100644 index 00000000000..7fac73eefbd --- /dev/null +++ b/packages/server/postgres/helpers/toGoogleAnalyzedEntity.ts @@ -0,0 +1,6 @@ +import {sql} from 'kysely' +import GoogleAnalyzedEntity from '../../database/types/GoogleAnalyzedEntity' +export const toGoogleAnalyzedEntity = (entities: GoogleAnalyzedEntity[]) => + sql< + string[] + >`(select coalesce(array_agg((name, salience, lemma)::"GoogleAnalyzedEntity"), '{}') from json_populate_recordset(null::"GoogleAnalyzedEntity", ${JSON.stringify(entities)}))` diff --git a/packages/server/postgres/migrations/1719435764047_Organization-phase1.ts b/packages/server/postgres/migrations/1719435764047_Organization-phase1.ts index f14b2e5cbfd..719d7a6953b 100644 --- a/packages/server/postgres/migrations/1719435764047_Organization-phase1.ts +++ b/packages/server/postgres/migrations/1719435764047_Organization-phase1.ts @@ -37,6 +37,8 @@ export async function up() { ); CREATE INDEX IF NOT EXISTS "idx_Organization_activeDomain" ON "Organization"("activeDomain"); CREATE INDEX IF NOT EXISTS "idx_Organization_tier" ON "Organization"("tier"); + DROP TRIGGER IF EXISTS "update_Organization_updatedAt" ON "Organization"; + CREATE TRIGGER "update_Organization_updatedAt" BEFORE UPDATE ON "Organization" FOR EACH ROW EXECUTE PROCEDURE "set_updatedAt"(); END $$; `) await client.end() diff --git a/packages/server/safeMutations/safeArchiveEmptyStarterOrganization.ts b/packages/server/safeMutations/safeArchiveEmptyStarterOrganization.ts index e4eba005f50..e53f082ffce 100644 --- a/packages/server/safeMutations/safeArchiveEmptyStarterOrganization.ts +++ b/packages/server/safeMutations/safeArchiveEmptyStarterOrganization.ts @@ -1,17 +1,21 @@ import getRethink from '../database/rethinkDriver' +import {DataLoaderInstance} from '../dataloader/RootDataLoader' import getTeamsByOrgIds from '../postgres/queries/getTeamsByOrgIds' // Only does something if the organization is empty & not paid // safeArchiveTeam & downgradeToStarter should be called before calling this -const safeArchiveEmptyStarterOrganization = async (orgId: string) => { +const safeArchiveEmptyStarterOrganization = async ( + orgId: string, + dataLoader: DataLoaderInstance +) => { const r = await getRethink() const now = new Date() const orgTeams = await getTeamsByOrgIds([orgId]) const teamCountRemainingOnOldOrg = orgTeams.length if (teamCountRemainingOnOldOrg > 0) return - const org = await r.table('Organization').get(orgId).run() + const org = await dataLoader.get('organizations').load(orgId) if (org.tier !== 'starter') return await r diff --git a/packages/server/utils/__tests__/isRequestToJoinDomainAllowed.test.ts b/packages/server/utils/__tests__/isRequestToJoinDomainAllowed.test.ts index 6858d2854cf..1a152cfa456 100644 --- a/packages/server/utils/__tests__/isRequestToJoinDomainAllowed.test.ts +++ b/packages/server/utils/__tests__/isRequestToJoinDomainAllowed.test.ts @@ -2,9 +2,11 @@ import {r} from 'rethinkdb-ts' import getRethinkConfig from '../../database/getRethinkConfig' import getRethink from '../../database/rethinkDriver' +import {TierEnum} from '../../database/types/Invoice' import OrganizationUser from '../../database/types/OrganizationUser' import generateUID from '../../generateUID' import {DataLoaderWorker} from '../../graphql/graphql' +import getKysely from '../../postgres/getKysely' import getRedis from '../getRedis' import {getEligibleOrgIdsByDomain} from '../isRequestToJoinDomainAllowed' jest.mock('../../database/rethinkDriver') @@ -44,7 +46,7 @@ type TestOrganizationUser = Partial< const addOrg = async ( activeDomain: string | null, members: TestOrganizationUser[], - rest?: {featureFlags?: string[]; tier?: string} + rest?: {featureFlags?: string[]; tier?: TierEnum} ) => { const {featureFlags, tier} = rest ?? {} const orgId = generateUID() @@ -52,6 +54,7 @@ const addOrg = async ( id: orgId, activeDomain, featureFlags, + name: 'foog', tier: tier ?? 'starter' } @@ -63,7 +66,7 @@ const addOrg = async ( role: member.role ?? null, removedAt: member.removedAt ?? null })) - + await getKysely().insertInto('Organization').values(org).execute() await r.table('Organization').insert(org).run() await r.table('OrganizationUser').insert(orgUsers).run() return orgId diff --git a/scripts/toolboxSrc/setIsEnterprise.ts b/scripts/toolboxSrc/setIsEnterprise.ts index 9aab65a8674..4fe84a5242b 100644 --- a/scripts/toolboxSrc/setIsEnterprise.ts +++ b/scripts/toolboxSrc/setIsEnterprise.ts @@ -1,22 +1,25 @@ +import getKysely from 'parabol-server/postgres/getKysely' import getRethink from '../../packages/server/database/rethinkDriver' import getPg from '../../packages/server/postgres/getPg' import {defaultTier} from '../../packages/server/utils/defaultTier' export default async function setIsEnterprise() { if (defaultTier !== 'enterprise') { - throw new Error('Environment variable IS_ENTERPRISE is not set to true. Exiting without updating tiers.') + throw new Error( + 'Environment variable IS_ENTERPRISE is not set to true. Exiting without updating tiers.' + ) } - + const r = await getRethink() console.log( 'Updating tier to "enterprise" for Organization and OrganizationUser tables in RethinkDB' ) - + type RethinkTableKey = 'Organization' | 'OrganizationUser' const tablesToUpdate: RethinkTableKey[] = ['Organization', 'OrganizationUser'] - + await getKysely().updateTable('Organization').set({tier: 'enterprise'}).execute() const rethinkPromises = tablesToUpdate.map(async (table) => { const result = await r .table(table) From f4f5edebbd456d7a04b769efbcbdcd22ce094ddf Mon Sep 17 00:00:00 2001 From: Matt Krick Date: Wed, 3 Jul 2024 09:29:58 -0700 Subject: [PATCH 27/47] fix: array append/remove Signed-off-by: Matt Krick --- .../graphql/private/mutations/updateOrgFeatureFlag.ts | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/packages/server/graphql/private/mutations/updateOrgFeatureFlag.ts b/packages/server/graphql/private/mutations/updateOrgFeatureFlag.ts index 98ae9bab56a..733d03ae10b 100644 --- a/packages/server/graphql/private/mutations/updateOrgFeatureFlag.ts +++ b/packages/server/graphql/private/mutations/updateOrgFeatureFlag.ts @@ -23,13 +23,10 @@ const updateOrgFeatureFlag: MutationResolvers['updateOrgFeatureFlag'] = async ( // RESOLUTION await getKysely() .updateTable('Organization') - .$if(addFlag, (db) => db.set({featureFlags: sql`ARRAY_APPEND("featureFlags",${flag})`})) + .$if(addFlag, (db) => db.set({featureFlags: sql`arr_append_uniq("featureFlags",${flag})`})) .$if(!addFlag, (db) => db.set({ - featureFlags: sql`array_cat( - "featureFlags"[1:array_position("featureFlags",${flag})-1], - "featureFlags"[array_position("featureFlags",${flag})+1:] - )` + featureFlags: sql`ARRAY_REMOVE("featureFlags",${flag})` }) ) .where('id', 'in', orgIds) From dcbcd789c4381edf96bd3f897cf3a4f5400def9b Mon Sep 17 00:00:00 2001 From: Matt Krick Date: Wed, 3 Jul 2024 11:31:05 -0700 Subject: [PATCH 28/47] chore: migrate existing orgs to PG Signed-off-by: Matt Krick --- .../mutations/checkRethinkPgEquality.ts | 55 ++++---- .../1720026588542_Organization-phase2.ts | 121 ++++++++++++++++++ .../postgres/utils/rethinkEqualityFns.ts | 26 +++- 3 files changed, 176 insertions(+), 26 deletions(-) create mode 100644 packages/server/postgres/migrations/1720026588542_Organization-phase2.ts diff --git a/packages/server/graphql/private/mutations/checkRethinkPgEquality.ts b/packages/server/graphql/private/mutations/checkRethinkPgEquality.ts index 954411c9f50..a01373429eb 100644 --- a/packages/server/graphql/private/mutations/checkRethinkPgEquality.ts +++ b/packages/server/graphql/private/mutations/checkRethinkPgEquality.ts @@ -4,12 +4,13 @@ import getKysely from '../../../postgres/getKysely' import {checkRowCount, checkTableEq} from '../../../postgres/utils/checkEqBase' import { compareDateAlmostEqual, - compareOptionalPlaintextContent, - compareRValOptionalPluckedArray, + compareRValOptionalPluckedObject, + compareRValStringAsNumber, compareRValUndefinedAsEmptyArray, + compareRValUndefinedAsFalse, compareRValUndefinedAsNull, compareRValUndefinedAsNullAndTruncateRVal, - compareRealNumber, + compareRValUndefinedAsZero, defaultEqFn } from '../../../postgres/utils/rethinkEqualityFns' import {MutationResolvers} from '../resolverTypes' @@ -36,11 +37,11 @@ const checkRethinkPgEquality: MutationResolvers['checkRethinkPgEquality'] = asyn ) => { const r = await getRethink() - if (tableName === 'RetroReflection') { + if (tableName === 'Organization') { const rowCountResult = await checkRowCount(tableName) const rethinkQuery = (updatedAt: Date, id: string | number) => { return r - .table('RetroReflection' as any) + .table('Organization' as any) .between([updatedAt, id], [r.maxval, r.maxval], { index: 'updatedAtId', leftBound: 'open', @@ -50,12 +51,9 @@ const checkRethinkPgEquality: MutationResolvers['checkRethinkPgEquality'] = asyn } const pgQuery = async (ids: string[]) => { return getKysely() - .selectFrom('RetroReflection') + .selectFrom('Organization') .selectAll() - .select(({fn}) => [ - fn('to_json', ['entities']).as('entities'), - fn('to_json', ['reactjis']).as('reactjis') - ]) + .select(({fn}) => [fn('to_json', ['creditCard']).as('creditCard')]) .where('id', 'in', ids) .execute() } @@ -64,23 +62,30 @@ const checkRethinkPgEquality: MutationResolvers['checkRethinkPgEquality'] = asyn pgQuery, { id: defaultEqFn, + activeDomain: compareRValUndefinedAsNullAndTruncateRVal(100), + isActiveDomainTouched: compareRValUndefinedAsFalse, + creditCard: compareRValOptionalPluckedObject({ + brand: compareRValUndefinedAsNull, + expiry: compareRValUndefinedAsNull, + last4: compareRValStringAsNumber + }), createdAt: defaultEqFn, + name: compareRValUndefinedAsNullAndTruncateRVal(100), + payLaterClickCount: compareRValUndefinedAsZero, + periodEnd: compareRValUndefinedAsNull, + periodStart: compareRValUndefinedAsNull, + picture: compareRValUndefinedAsNull, + showConversionModal: compareRValUndefinedAsFalse, + stripeId: compareRValUndefinedAsNull, + stripeSubscriptionId: compareRValUndefinedAsNull, + upcomingInvoiceEmailSentAt: compareRValUndefinedAsNull, + tier: defaultEqFn, + tierLimitExceededAt: compareRValUndefinedAsNull, + trialStartDate: compareRValUndefinedAsNull, + scheduledLockAt: compareRValUndefinedAsNull, + lockedAt: compareRValUndefinedAsNull, updatedAt: compareDateAlmostEqual, - isActive: defaultEqFn, - meetingId: defaultEqFn, - promptId: defaultEqFn, - creatorId: compareRValUndefinedAsNull, - sortOrder: defaultEqFn, - reflectionGroupId: defaultEqFn, - content: compareRValUndefinedAsNullAndTruncateRVal(2000, 0.19), - plaintextContent: compareOptionalPlaintextContent, - entities: compareRValOptionalPluckedArray({ - name: defaultEqFn, - salience: compareRealNumber, - lemma: compareRValUndefinedAsNull - }), - reactjis: compareRValUndefinedAsEmptyArray, - sentimentScore: compareRValUndefinedAsNull + featureFlags: compareRValUndefinedAsEmptyArray }, maxErrors ) diff --git a/packages/server/postgres/migrations/1720026588542_Organization-phase2.ts b/packages/server/postgres/migrations/1720026588542_Organization-phase2.ts new file mode 100644 index 00000000000..78776890b41 --- /dev/null +++ b/packages/server/postgres/migrations/1720026588542_Organization-phase2.ts @@ -0,0 +1,121 @@ +import {Kysely, PostgresDialect, sql} from 'kysely' +import {r} from 'rethinkdb-ts' +import connectRethinkDB from '../../database/connectRethinkDB' +import getPg from '../getPg' + +const toCreditCard = (creditCard: any) => { + if (!creditCard) return null + return sql`(select json_populate_record(null::"CreditCard", ${JSON.stringify(creditCard)}))` +} + +export async function up() { + await connectRethinkDB() + const pg = new Kysely({ + dialect: new PostgresDialect({ + pool: getPg() + }) + }) + try { + console.log('Adding index') + await r + .table('Organization') + .indexCreate('updatedAtId', (row: any) => [row('updatedAt'), row('id')]) + .run() + await r.table('Organization').indexWait().run() + } catch { + // index already exists + } + console.log('Adding index complete') + const MAX_PG_PARAMS = 65545 + const PG_COLS = [ + 'id', + 'activeDomain', + 'isActiveDomainTouched', + 'creditCard', + 'createdAt', + 'name', + 'payLaterClickCount', + 'periodEnd', + 'periodStart', + 'picture', + 'showConversionModal', + 'stripeId', + 'stripeSubscriptionId', + 'upcomingInvoiceEmailSentAt', + 'tier', + 'tierLimitExceededAt', + 'trialStartDate', + 'scheduledLockAt', + 'lockedAt', + 'updatedAt', + 'featureFlags' + ] as const + type Organization = { + [K in (typeof PG_COLS)[number]]: any + } + const BATCH_SIZE = Math.trunc(MAX_PG_PARAMS / PG_COLS.length) + + let curUpdatedAt = r.minval + let curId = r.minval + for (let i = 0; i < 1e6; i++) { + console.log('inserting row', i * BATCH_SIZE, curUpdatedAt, curId) + const rawRowsToInsert = (await r + .table('Organization') + .between([curUpdatedAt, curId], [r.maxval, r.maxval], { + index: 'updatedAtId', + leftBound: 'open', + rightBound: 'closed' + }) + .orderBy({index: 'updatedAtId'}) + .limit(BATCH_SIZE) + .pluck(...PG_COLS) + .run()) as Organization[] + + const rowsToInsert = rawRowsToInsert.map((row) => ({ + ...row, + activeDomain: row.activeDomain?.slice(0, 100) ?? null, + name: row.name.slice(0, 100), + creditCard: toCreditCard(row.creditCard) + })) + if (rowsToInsert.length === 0) break + const lastRow = rowsToInsert[rowsToInsert.length - 1] + curUpdatedAt = lastRow.updatedAt + curId = lastRow.id + try { + await pg + .insertInto('Organization') + .values(rowsToInsert) + .onConflict((oc) => oc.doNothing()) + .execute() + } catch (e) { + await Promise.all( + rowsToInsert.map(async (row) => { + try { + await pg + .insertInto('Organization') + .values(row) + .onConflict((oc) => oc.doNothing()) + .execute() + } catch (e) { + console.log(e, row) + } + }) + ) + } + } +} + +export async function down() { + await connectRethinkDB() + try { + await r.table('Organization').indexDrop('updatedAtId').run() + } catch { + // index already dropped + } + const pg = new Kysely({ + dialect: new PostgresDialect({ + pool: getPg() + }) + }) + await pg.deleteFrom('Organization').execute() +} diff --git a/packages/server/postgres/utils/rethinkEqualityFns.ts b/packages/server/postgres/utils/rethinkEqualityFns.ts index 8a7282be5f4..3d63e8381df 100644 --- a/packages/server/postgres/utils/rethinkEqualityFns.ts +++ b/packages/server/postgres/utils/rethinkEqualityFns.ts @@ -4,6 +4,7 @@ import stringSimilarity from 'string-similarity' export const defaultEqFn = (a: unknown, b: unknown) => { if (a instanceof Date && b instanceof Date) return a.getTime() === b.getTime() if (Array.isArray(a) && Array.isArray(b)) return JSON.stringify(a) === JSON.stringify(b) + if (typeof a === 'object' && typeof b === 'object') return JSON.stringify(a) === JSON.stringify(b) return a === b } export const compareDateAlmostEqual = (rVal: unknown, pgVal: unknown) => { @@ -30,11 +31,33 @@ export const compareRValUndefinedAsFalse = (rVal: unknown, pgVal: unknown) => { return normalizedRVal === pgVal } +export const compareRValUndefinedAsZero = (rVal: unknown, pgVal: unknown) => { + const normalizedRVal = rVal === undefined ? 0 : rVal + return normalizedRVal === pgVal +} + export const compareRValUndefinedAsEmptyArray = (rVal: unknown, pgVal: unknown) => { const normalizedRVal = rVal === undefined ? [] : rVal return defaultEqFn(normalizedRVal, pgVal) } +export const compareRValStringAsNumber = (rVal: unknown, pgVal: unknown) => { + const normalizedRVal = Number(rVal) + return defaultEqFn(normalizedRVal, pgVal) +} + +export const compareRValOptionalPluckedObject = + (pluckFields: Record) => (rVal: unknown, pgVal: unknown) => { + if (!rVal && !pgVal) return true + const rValObj = rVal || {} + const pgValItem = pgVal || {} + return Object.keys(pluckFields).every((prop) => { + const eqFn = pluckFields[prop]! + const rValItemProp = rValObj[prop as keyof typeof rValObj] + const pgValItemProp = pgValItem[prop as keyof typeof pgValItem] + return eqFn(rValItemProp, pgValItemProp) + }) + } export const compareRValOptionalPluckedArray = (pluckFields: Record) => (rVal: unknown, pgVal: unknown) => { const rValArray = Array.isArray(rVal) ? rVal : [] @@ -56,7 +79,8 @@ export const compareRValOptionalPluckedArray = } export const compareRValUndefinedAsNullAndTruncateRVal = - (length: number, similarity?: number) => (rVal: unknown, pgVal: unknown) => { + (length: number, similarity = 1) => + (rVal: unknown, pgVal: unknown) => { const truncatedRVal = typeof rVal === 'string' ? rVal.slice(0, length) : rVal const normalizedRVal = truncatedRVal === undefined ? null : truncatedRVal if ( From b329fd38710792c2ed954066b90c93c28b8b516d Mon Sep 17 00:00:00 2001 From: Matt Krick Date: Wed, 3 Jul 2024 18:14:33 -0700 Subject: [PATCH 29/47] remove RethinkDB.Organization Signed-off-by: Matt Krick --- codegen.json | 9 +- .../email/components/UpcomingInvoiceEmail.tsx | 68 -------- .../server/billing/helpers/adjustUserCount.ts | 14 +- .../server/billing/helpers/fetchAllLines.ts | 2 +- .../server/billing/helpers/generateInvoice.ts | 5 +- .../helpers/generateUpcomingInvoice.ts | 2 +- .../handleEnterpriseOrgQuantityChanges.ts | 6 +- .../helpers/handleTeamOrgQuantityChanges.ts | 4 +- .../server/billing/helpers/teamLimitsCheck.ts | 29 +--- .../billing/helpers/terminateSubscription.ts | 47 ++---- .../helpers/updateSubscriptionQuantity.ts | 7 +- packages/server/database/rethinkDriver.ts | 5 - packages/server/database/types/Invoice.ts | 4 +- .../types/NotificationPaymentRejected.ts | 4 +- .../types/NotificationTeamsLimitExceeded.ts | 4 +- .../types/NotificationTeamsLimitReminder.ts | 4 +- .../database/types/processTeamsLimitsJob.ts | 15 +- .../__tests__/isOrgVerified.test.ts | 28 +++- .../server/dataloader/customLoaderMakers.ts | 73 +++------ .../dataloader/foreignKeyLoaderMakers.ts | 13 +- .../dataloader/primaryKeyLoaderMakers.ts | 78 ++++++++- .../rethinkForeignKeyLoaderMakers.ts | 8 - .../rethinkPrimaryKeyLoaderMakers.ts | 1 - .../email/UpcomingInvoiceEmailTemplate.tsx | 37 ----- packages/server/graphql/mutations/addTeam.ts | 4 +- .../graphql/mutations/archiveOrganization.ts | 2 +- .../graphql/mutations/downgradeToStarter.ts | 2 +- .../mutations/helpers/canAccessAISummary.ts | 6 +- .../graphql/mutations/helpers/createNewOrg.ts | 5 +- .../mutations/helpers/createTeamAndLeader.ts | 2 +- .../endMeeting/sendNewMeetingSummary.ts | 2 +- .../mutations/helpers/generateGroups.ts | 2 +- .../mutations/helpers/hideConversionModal.ts | 9 +- .../mutations/helpers/inviteToTeamHelper.ts | 2 +- .../mutations/helpers/isStartMeetingLocked.ts | 2 +- .../helpers/notifications/MSTeamsNotifier.ts | 4 +- .../notifications/MattermostNotifier.ts | 16 +- .../NotificationIntegrationHelper.ts | 14 +- .../helpers/notifications/SlackNotifier.ts | 12 +- .../mutations/helpers/oldUpgradeToTeamTier.ts | 35 ++-- .../mutations/helpers/removeFromOrg.ts | 2 +- .../helpers/resolveDowngradeToStarter.ts | 14 +- .../helpers/safeCreateRetrospective.ts | 2 +- .../server/graphql/mutations/moveTeamToOrg.ts | 2 +- .../graphql/mutations/oldUpgradeToTeamTier.ts | 4 +- packages/server/graphql/mutations/payLater.ts | 8 - .../private/mutations/backupOrganization.ts | 3 - .../private/mutations/changeEmailDomain.ts | 5 - .../mutations/draftEnterpriseInvoice.ts | 20 +-- .../graphql/private/mutations/endTrial.ts | 15 +- .../private/mutations/flagConversionModal.ts | 10 -- .../private/mutations/processRecurrence.ts | 2 +- .../mutations/sendUpcomingInvoiceEmails.ts | 153 ------------------ .../mutations/setOrganizationDomain.ts | 11 +- .../graphql/private/mutations/startTrial.ts | 17 +- .../mutations/stripeCreateSubscription.ts | 10 -- .../mutations/stripeDeleteSubscription.ts | 15 +- .../private/mutations/stripeInvoicePaid.ts | 20 +-- .../private/mutations/stripeSucceedPayment.ts | 20 +-- .../mutations/stripeUpdateCreditCard.ts | 3 - .../private/mutations/updateOrgFeatureFlag.ts | 23 +-- .../private/mutations/upgradeToTeamTier.ts | 18 +-- .../graphql/private/queries/suProOrgInfo.ts | 31 ++-- .../types/DraftEnterpriseInvoicePayload.ts | 2 +- .../graphql/private/types/EndTrialSuccess.ts | 9 +- .../types/FlagConversionModalPayload.ts | 2 +- .../private/types/StartTrialSuccess.ts | 9 +- .../mutations/acceptRequestToJoinDomain.ts | 2 +- .../mutations/createStripeSubscription.ts | 2 +- .../public/mutations/setMeetingSettings.ts | 2 +- .../public/mutations/updateCreditCard.ts | 20 +-- .../graphql/public/mutations/updateOrg.ts | 9 -- .../public/mutations/uploadOrgImage.ts | 12 -- .../AddApprovedOrganizationDomainsSuccess.ts | 2 +- .../public/types/AddPokerTemplateSuccess.ts | 4 +- .../graphql/public/types/DomainJoinRequest.ts | 2 +- .../server/graphql/public/types/NewMeeting.ts | 2 +- .../public/types/NotifyPaymentRejected.ts | 2 +- .../public/types/NotifyPromoteToOrgLeader.ts | 2 +- .../graphql/public/types/Organization.ts | 5 + ...emoveApprovedOrganizationDomainsSuccess.ts | 2 +- packages/server/graphql/public/types/SAML.ts | 2 +- .../public/types/SetOrgUserRoleSuccess.ts | 2 +- .../public/types/StripeFailPaymentPayload.ts | 2 +- packages/server/graphql/public/types/Team.ts | 4 + .../public/types/UpdateCreditCardSuccess.ts | 2 +- .../graphql/public/types/UpdateOrgPayload.ts | 2 +- .../public/types/UpgradeToTeamTierSuccess.ts | 2 +- packages/server/graphql/public/types/User.ts | 2 +- .../queries/helpers/countTiersForUserId.ts | 4 - .../queries/helpers/makeUpcomingInvoice.ts | 4 +- packages/server/graphql/queries/invoices.ts | 4 +- packages/server/graphql/types/Organization.ts | 4 +- packages/server/graphql/types/Team.ts | 2 +- packages/server/graphql/types/User.ts | 14 +- .../graphql/types/helpers/isMeetingLocked.ts | 2 +- .../queries/src/updateUserTiersQuery.sql | 9 -- .../postgres/queries/updateUserTiers.ts | 11 -- .../safeMutations/acceptTeamInvitation.ts | 10 +- .../safeArchiveEmptyStarterOrganization.ts | 2 +- .../isRequestToJoinDomainAllowed.test.ts | 12 +- .../utils/isRequestToJoinDomainAllowed.ts | 104 +++++------- packages/server/utils/setTierForOrgUsers.ts | 24 +-- .../server/utils/setUserTierForUserIds.ts | 99 ++++++------ scripts/toolboxSrc/setIsEnterprise.ts | 22 +-- 105 files changed, 492 insertions(+), 937 deletions(-) delete mode 100644 packages/client/modules/email/components/UpcomingInvoiceEmail.tsx delete mode 100644 packages/server/email/UpcomingInvoiceEmailTemplate.tsx delete mode 100644 packages/server/graphql/private/mutations/sendUpcomingInvoiceEmails.ts delete mode 100644 packages/server/postgres/queries/src/updateUserTiersQuery.sql delete mode 100644 packages/server/postgres/queries/updateUserTiers.ts diff --git a/codegen.json b/codegen.json index fa006c77adb..cfa97845070 100644 --- a/codegen.json +++ b/codegen.json @@ -21,7 +21,7 @@ "LoginsPayload": "./types/LoginsPayload#LoginsPayloadSource", "MeetingTemplate": "../../database/types/MeetingTemplate#default as IMeetingTemplate", "NewMeeting": "../../postgres/types/Meeting#AnyMeeting", - "Organization": "../../database/types/Organization#default as Organization", + "Organization": "../public/types/Organization#OrganizationSource", "PingableServices": "./types/PingableServices#PingableServicesSource", "ProcessRecurrenceSuccess": "./types/ProcessRecurrenceSuccess#ProcessRecurrenceSuccessSource", "RemoveAuthIdentitySuccess": "./types/RemoveAuthIdentitySuccess#RemoveAuthIdentitySuccessSource", @@ -30,7 +30,7 @@ "SignupsPayload": "./types/SignupsPayload#SignupsPayloadSource", "StartTrialSuccess": "./types/StartTrialSuccess#StartTrialSuccessSource", "StripeFailPaymentPayload": "./mutations/stripeFailPayment#StripeFailPaymentPayloadSource", - "Team": "../../postgres/queries/getTeamsByIds#Team", + "Team": "../public/types/Team#TeamSource", "UpdateOrgFeatureFlagSuccess": "./types/UpdateOrgFeatureFlagSuccess#UpdateOrgFeatureFlagSuccessSource", "UpgradeToTeamTierSuccess": "./mutations/upgradeToTeamTier#UpgradeToTeamTierSuccessSource", "User": "../../postgres/types/IUser#default as IUser", @@ -91,10 +91,11 @@ "NotifyResponseReplied": "../../database/types/NotifyResponseReplied#default as NotifyResponseRepliedDB", "NotifyTaskInvolves": "../../database/types/NotificationTaskInvolves#default", "NotifyTeamArchived": "../../database/types/NotificationTeamArchived#default", - "Organization": "../../database/types/Organization#default as Organization", + "Organization": "./types/Organization#OrganizationSource", "OrganizationUser": "../../database/types/OrganizationUser#default as OrganizationUser", "PokerMeeting": "../../database/types/MeetingPoker#default as MeetingPoker", "PokerMeetingMember": "../../database/types/MeetingPokerMeetingMember#default as PokerMeetingMemberDB", + "PokerTemplate": "../../database/types/PokerTemplate#default as PokerTemplateDB", "RRule": "rrule#RRule", "Reactable": "../../database/types/Reactable#Reactable", "Reactji": "../types/Reactji#ReactjiSource", @@ -120,7 +121,7 @@ "StartTeamPromptSuccess": "./types/StartTeamPromptSuccess#StartTeamPromptSuccessSource", "StripeFailPaymentPayload": "./types/StripeFailPaymentPayload#StripeFailPaymentPayloadSource", "Task": "../../database/types/Task#default", - "Team": "../../postgres/queries/getTeamsByIds#Team", + "Team": "./types/Team#TeamSource", "TeamHealthPhase": "./types/TeamHealthPhase#TeamHealthPhaseSource", "TeamHealthStage": "./types/TeamHealthStage#TeamHealthStageSource", "TeamInvitation": "../../database/types/TeamInvitation#default", diff --git a/packages/client/modules/email/components/UpcomingInvoiceEmail.tsx b/packages/client/modules/email/components/UpcomingInvoiceEmail.tsx deleted file mode 100644 index d285f9af4d3..00000000000 --- a/packages/client/modules/email/components/UpcomingInvoiceEmail.tsx +++ /dev/null @@ -1,68 +0,0 @@ -import React from 'react' -import {EMAIL_CORS_OPTIONS} from '../../../types/cors' -import {emailCopyStyle, emailLinkStyle, emailProductTeamSignature} from '../styles' -import EmailBlock from './EmailBlock/EmailBlock' -import EmailFooter from './EmailFooter/EmailFooter' -import EmptySpace from './EmptySpace/EmptySpace' -import Header from './Header/Header' -import Layout from './Layout/Layout' - -const innerMaxWidth = 480 - -const listItemStyle = { - ...emailCopyStyle, - margin: 0 -} - -export interface UpcomingInvoiceEmailProps { - appOrigin: string - memberUrl: string - periodEndStr: string - newUsers: {email: string; name: string}[] -} - -const UpcomingInvoiceEmail = (props: UpcomingInvoiceEmailProps) => { - const {appOrigin, periodEndStr, newUsers, memberUrl} = props - return ( - - -
-

{'Hello, '}

-

- {`Your teams have added the following users to your organization for the billing cycle ending on ${periodEndStr}.`} -

-
    - {newUsers.map((newUser) => ( -
  • - {`${newUser.name}`} - {' ('} - {`${newUser.email}`} - {')'} -
  • - ))} -
-

- {'If any of these users were added by mistake, simply remove them under: '} - - {'Organization Settings'} - -

-

- {'Get in touch if we can help in any way,'} -
- {emailProductTeamSignature} -
- - {'love@parabol.co'} - -

- - - - - - - ) -} - -export default UpcomingInvoiceEmail diff --git a/packages/server/billing/helpers/adjustUserCount.ts b/packages/server/billing/helpers/adjustUserCount.ts index 649f568f161..6fd57f11267 100644 --- a/packages/server/billing/helpers/adjustUserCount.ts +++ b/packages/server/billing/helpers/adjustUserCount.ts @@ -22,8 +22,7 @@ const maybeUpdateOrganizationActiveDomain = async ( newUserEmail: string, dataLoader: DataLoaderWorker ) => { - const r = await getRethink() - const organization = await dataLoader.get('organizations').load(orgId) + const organization = await dataLoader.get('organizations').loadNonNull(orgId) const {isActiveDomainTouched, activeDomain} = organization // don't modify if the domain was set manually if (isActiveDomainTouched) return @@ -41,16 +40,7 @@ const maybeUpdateOrganizationActiveDomain = async ( if (!domain || domain === activeDomain) return organization.activeDomain = domain const pg = getKysely() - await Promise.all([ - pg.updateTable('Organization').set({activeDomain: domain}).where('id', '=', orgId).execute(), - r - .table('Organization') - .get(orgId) - .update({ - activeDomain: domain - }) - .run() - ]) + await pg.updateTable('Organization').set({activeDomain: domain}).where('id', '=', orgId).execute() } const changePause = (inactive: boolean) => async (_orgIds: string[], user: IUser) => { diff --git a/packages/server/billing/helpers/fetchAllLines.ts b/packages/server/billing/helpers/fetchAllLines.ts index fc97e0b3b66..7f8c30d0e4b 100644 --- a/packages/server/billing/helpers/fetchAllLines.ts +++ b/packages/server/billing/helpers/fetchAllLines.ts @@ -1,7 +1,7 @@ import Stripe from 'stripe' import {getStripeManager} from '../../utils/stripe' -export default async function fetchAllLines(invoiceId: string, customerId?: string) { +export default async function fetchAllLines(invoiceId: string, customerId?: string | null) { const stripeLineItems = [] as Stripe.InvoiceLineItem[] const options = {limit: 100} as Stripe.InvoiceLineItemListParams & {customer: string} // used for upcoming invoices diff --git a/packages/server/billing/helpers/generateInvoice.ts b/packages/server/billing/helpers/generateInvoice.ts index 1b75d4a6537..cebf0d6c88f 100644 --- a/packages/server/billing/helpers/generateInvoice.ts +++ b/packages/server/billing/helpers/generateInvoice.ts @@ -354,7 +354,7 @@ export default async function generateInvoice( ? fromEpochSeconds(invoice.status_transitions.paid_at) : undefined const [organization, billingLeaderIds] = await Promise.all([ - dataLoader.get('organizations').load(orgId), + dataLoader.get('organizations').loadNonNull(orgId), r .table('OrganizationUser') .getAll(orgId, {index: 'orgId'}) @@ -378,6 +378,7 @@ export default async function generateInvoice( })) || null + const {creditCard} = organization const dbInvoice = new Invoice({ id: invoiceId, amountDue: invoice.amount_due, @@ -385,7 +386,7 @@ export default async function generateInvoice( coupon, total: invoice.total, billingLeaderEmails, - creditCard: organization.creditCard, + creditCard: creditCard ? {...creditCard, last4: String(creditCard.last4)} : undefined, endAt: fromEpochSeconds(invoice.period_end), invoiceDate: fromEpochSeconds(invoice.due_date!), lines: invoiceLineItems, diff --git a/packages/server/billing/helpers/generateUpcomingInvoice.ts b/packages/server/billing/helpers/generateUpcomingInvoice.ts index 7980c3871df..ddc4b1b3a12 100644 --- a/packages/server/billing/helpers/generateUpcomingInvoice.ts +++ b/packages/server/billing/helpers/generateUpcomingInvoice.ts @@ -6,7 +6,7 @@ import generateInvoice from './generateInvoice' const generateUpcomingInvoice = async (orgId: string, dataLoader: DataLoaderWorker) => { const invoiceId = getUpcomingInvoiceId(orgId) - const organization = await dataLoader.get('organizations').load(orgId) + const organization = await dataLoader.get('organizations').loadNonNull(orgId) const {stripeId} = organization const manager = getStripeManager() const [stripeLineItems, upcomingInvoice] = await Promise.all([ diff --git a/packages/server/billing/helpers/handleEnterpriseOrgQuantityChanges.ts b/packages/server/billing/helpers/handleEnterpriseOrgQuantityChanges.ts index ad62e1fadd4..215ac9396ef 100644 --- a/packages/server/billing/helpers/handleEnterpriseOrgQuantityChanges.ts +++ b/packages/server/billing/helpers/handleEnterpriseOrgQuantityChanges.ts @@ -1,12 +1,12 @@ import getRethink from '../../database/rethinkDriver' import {RDatum} from '../../database/stricterR' -import Organization from '../../database/types/Organization' import {DataLoaderWorker} from '../../graphql/graphql' +import {OrganizationSource} from '../../graphql/public/types/Organization' import {analytics} from '../../utils/analytics/analytics' import {getStripeManager} from '../../utils/stripe' const sendEnterpriseOverageEvent = async ( - organization: Organization, + organization: OrganizationSource, dataLoader: DataLoaderWorker ) => { const r = await getRethink() @@ -41,7 +41,7 @@ const sendEnterpriseOverageEvent = async ( } const handleEnterpriseOrgQuantityChanges = async ( - paidOrgs: Organization[], + paidOrgs: OrganizationSource[], dataLoader: DataLoaderWorker ) => { const enterpriseOrgs = paidOrgs.filter((org) => org.tier === 'enterprise') diff --git a/packages/server/billing/helpers/handleTeamOrgQuantityChanges.ts b/packages/server/billing/helpers/handleTeamOrgQuantityChanges.ts index 4c3040e8117..2ae1f729a64 100644 --- a/packages/server/billing/helpers/handleTeamOrgQuantityChanges.ts +++ b/packages/server/billing/helpers/handleTeamOrgQuantityChanges.ts @@ -1,7 +1,7 @@ -import Organization from '../../database/types/Organization' +import {OrganizationSource} from '../../graphql/public/types/Organization' import updateSubscriptionQuantity from './updateSubscriptionQuantity' -const handleTeamOrgQuantityChanges = async (paidOrgs: Organization[]) => { +const handleTeamOrgQuantityChanges = async (paidOrgs: OrganizationSource[]) => { const teamOrgs = paidOrgs.filter((org) => org.tier === 'team') if (teamOrgs.length === 0) return diff --git a/packages/server/billing/helpers/teamLimitsCheck.ts b/packages/server/billing/helpers/teamLimitsCheck.ts index fff842326db..6652c8ea6c7 100644 --- a/packages/server/billing/helpers/teamLimitsCheck.ts +++ b/packages/server/billing/helpers/teamLimitsCheck.ts @@ -5,10 +5,10 @@ import {Threshold} from 'parabol-client/types/constEnums' import {sql} from 'kysely' import {r} from 'rethinkdb-ts' import NotificationTeamsLimitExceeded from '../../database/types/NotificationTeamsLimitExceeded' -import Organization from '../../database/types/Organization' import scheduleTeamLimitsJobs from '../../database/types/scheduleTeamLimitsJobs' import {DataLoaderWorker} from '../../graphql/graphql' import publishNotification from '../../graphql/public/mutations/helpers/publishNotification' +import {OrganizationSource} from '../../graphql/public/types/Organization' import getActiveTeamCountByTeamIds from '../../graphql/public/types/helpers/getActiveTeamCountByTeamIds' import {getFeatureTier} from '../../graphql/types/helpers/getFeatureTier' import {domainHasActiveDeals} from '../../hubSpot/hubSpotApi' @@ -35,7 +35,7 @@ const enableUsageStats = async (userIds: string[], orgId: string) => { } const sendWebsiteNotifications = async ( - organization: Organization, + organization: OrganizationSource, userIds: string[], dataLoader: DataLoaderWorker ) => { @@ -72,7 +72,7 @@ const isLimitExceeded = async (orgId: string) => { // Warning: the function might be expensive export const maybeRemoveRestrictions = async (orgId: string, dataLoader: DataLoaderWorker) => { - const organization = await dataLoader.get('organizations').load(orgId) + const organization = await dataLoader.get('organizations').loadNonNull(orgId) if (!organization.tierLimitExceededAt) { return @@ -87,16 +87,6 @@ export const maybeRemoveRestrictions = async (orgId: string, dataLoader: DataLoa .set({tierLimitExceededAt: null, scheduledLockAt: null, lockedAt: null}) .where('id', '=', orgId) .execute(), - r - .table('Organization') - .get(orgId) - .update({ - tierLimitExceededAt: null, - scheduledLockAt: null, - lockedAt: null, - updatedAt: new Date() - }) - .run(), r .table('OrganizationUser') .getAll(r.args(billingLeadersIds), {index: 'userId'}) @@ -111,7 +101,7 @@ export const maybeRemoveRestrictions = async (orgId: string, dataLoader: DataLoa // Warning: the function might be expensive export const checkTeamsLimit = async (orgId: string, dataLoader: DataLoaderWorker) => { - const organization = await dataLoader.get('organizations').load(orgId) + const organization = await dataLoader.get('organizations').loadNonNull(orgId) const {tierLimitExceededAt, tier, trialStartDate, featureFlags, name: orgName} = organization if (!featureFlags?.includes('teamsLimit')) return @@ -144,16 +134,7 @@ export const checkTeamsLimit = async (orgId: string, dataLoader: DataLoaderWorke scheduledLockAt }) .where('id', '=', orgId) - .execute(), - r - .table('Organization') - .get(orgId) - .update({ - tierLimitExceededAt: now, - scheduledLockAt, - updatedAt: now - }) - .run() + .execute() ]) dataLoader.get('organizations').clear(orgId) diff --git a/packages/server/billing/helpers/terminateSubscription.ts b/packages/server/billing/helpers/terminateSubscription.ts index 1850a614828..200e451fbb5 100644 --- a/packages/server/billing/helpers/terminateSubscription.ts +++ b/packages/server/billing/helpers/terminateSubscription.ts @@ -1,45 +1,24 @@ -import getRethink from '../../database/rethinkDriver' -import Organization from '../../database/types/Organization' +import {sql} from 'kysely' import getKysely from '../../postgres/getKysely' import {Logger} from '../../utils/Logger' -import sendToSentry from '../../utils/sendToSentry' import {getStripeManager} from '../../utils/stripe' const terminateSubscription = async (orgId: string) => { - const r = await getRethink() const pg = getKysely() - const now = new Date() // flag teams as unpaid - const [pgOrganization, organization] = await Promise.all([ - pg - .with('OldOrg', (qc) => - qc.selectFrom('Organization').select('stripeSubscriptionId').where('id', '=', orgId) - ) - .updateTable('Organization') - .set({periodEnd: now, stripeSubscriptionId: null}) - .where('id', '=', orgId) - .returning((qc) => - qc.selectFrom('OldOrg').select('stripeSubscriptionId').as('stripeSubscriptionId') - ) - .executeTakeFirst(), - r - .table('Organization') - .get(orgId) - .update( - { - // periodEnd should always be redundant, but useful for testing purposes - periodEnd: now, - stripeSubscriptionId: null - }, - {returnChanges: true} - )('changes')(0)('old_val') - .default(null) - .run() as unknown as Organization - ]) + const organization = await pg + .with('OldOrg', (qc) => + qc.selectFrom('Organization').select('stripeSubscriptionId').where('id', '=', orgId) + ) + .updateTable('Organization') + .set({periodEnd: sql`CURRENT_TIMESTAMP`, stripeSubscriptionId: null}) + .where('id', '=', orgId) + .returning((qc) => + qc.selectFrom('OldOrg').select('stripeSubscriptionId').as('stripeSubscriptionId') + ) + .executeTakeFirstOrThrow() const {stripeSubscriptionId} = organization - if (stripeSubscriptionId !== pgOrganization?.stripeSubscriptionId) { - sendToSentry(new Error(`stripeSubscriptionId mismatch for orgId ${orgId}`)) - } + if (stripeSubscriptionId) { const manager = getStripeManager() try { diff --git a/packages/server/billing/helpers/updateSubscriptionQuantity.ts b/packages/server/billing/helpers/updateSubscriptionQuantity.ts index 25917b13e58..23fcb80095f 100644 --- a/packages/server/billing/helpers/updateSubscriptionQuantity.ts +++ b/packages/server/billing/helpers/updateSubscriptionQuantity.ts @@ -1,4 +1,5 @@ import getRethink from '../../database/rethinkDriver' +import getKysely from '../../postgres/getKysely' import insertStripeQuantityMismatchLogging from '../../postgres/queries/insertStripeQuantityMismatchLogging' import RedisLockQueue from '../../utils/RedisLockQueue' import sendToSentry from '../../utils/sendToSentry' @@ -12,7 +13,11 @@ const updateSubscriptionQuantity = async (orgId: string, logMismatch?: boolean) const r = await getRethink() const manager = getStripeManager() - const org = await r.table('Organization').get(orgId).run() + const org = await getKysely() + .selectFrom('Organization') + .selectAll() + .where('id', '=', orgId) + .executeTakeFirst() if (!org) throw new Error(`org not found for invoice`) const {stripeSubscriptionId, tier} = org diff --git a/packages/server/database/rethinkDriver.ts b/packages/server/database/rethinkDriver.ts index d88791af467..3eb79efcc06 100644 --- a/packages/server/database/rethinkDriver.ts +++ b/packages/server/database/rethinkDriver.ts @@ -1,5 +1,4 @@ import {MasterPool, r} from 'rethinkdb-ts' -import Organization from '../database/types/Organization' import SlackAuth from '../database/types/SlackAuth' import SlackNotification from '../database/types/SlackNotification' import TeamInvitation from '../database/types/TeamInvitation' @@ -113,10 +112,6 @@ export type RethinkSchema = { | NotificationMentioned index: 'userId' } - Organization: { - type: Organization - index: 'tier' | 'activeDomain' - } OrganizationUser: { type: OrganizationUser index: 'orgId' | 'userId' diff --git a/packages/server/database/types/Invoice.ts b/packages/server/database/types/Invoice.ts index 81fa0bff0e3..ee34077a5de 100644 --- a/packages/server/database/types/Invoice.ts +++ b/packages/server/database/types/Invoice.ts @@ -13,7 +13,7 @@ interface Input { coupon?: Coupon | null total: number billingLeaderEmails: string[] - creditCard?: CreditCard + creditCard?: CreditCard | null endAt: Date invoiceDate: Date lines: InvoiceLineItem[] @@ -36,7 +36,7 @@ export default class Invoice { coupon?: Coupon | null total: number billingLeaderEmails: string[] - creditCard?: CreditCard + creditCard?: CreditCard | null endAt: Date invoiceDate: Date lines: InvoiceLineItem[] diff --git a/packages/server/database/types/NotificationPaymentRejected.ts b/packages/server/database/types/NotificationPaymentRejected.ts index c6cb6cda233..53a779c47d3 100644 --- a/packages/server/database/types/NotificationPaymentRejected.ts +++ b/packages/server/database/types/NotificationPaymentRejected.ts @@ -2,7 +2,7 @@ import Notification from './Notification' interface Input { orgId: string - last4: string + last4: string | number brand: string userId: string } @@ -17,7 +17,7 @@ export default class NotificationPaymentRejected extends Notification { const {orgId, last4, brand, userId} = input super({userId, type: 'PAYMENT_REJECTED'}) this.orgId = orgId - this.last4 = last4 + this.last4 = String(last4) this.brand = brand } } diff --git a/packages/server/database/types/NotificationTeamsLimitExceeded.ts b/packages/server/database/types/NotificationTeamsLimitExceeded.ts index d67ff7a4ad6..8a070846a9c 100644 --- a/packages/server/database/types/NotificationTeamsLimitExceeded.ts +++ b/packages/server/database/types/NotificationTeamsLimitExceeded.ts @@ -3,7 +3,7 @@ import Notification from './Notification' interface Input { orgId: string orgName: string - orgPicture?: string + orgPicture?: string | null userId: string } @@ -11,7 +11,7 @@ export default class NotificationTeamsLimitExceeded extends Notification { readonly type = 'TEAMS_LIMIT_EXCEEDED' orgId: string orgName: string - orgPicture?: string + orgPicture?: string | null constructor(input: Input) { const {userId, orgId, orgName, orgPicture} = input super({userId, type: 'TEAMS_LIMIT_EXCEEDED'}) diff --git a/packages/server/database/types/NotificationTeamsLimitReminder.ts b/packages/server/database/types/NotificationTeamsLimitReminder.ts index 720b47c04d7..a03c33edb9c 100644 --- a/packages/server/database/types/NotificationTeamsLimitReminder.ts +++ b/packages/server/database/types/NotificationTeamsLimitReminder.ts @@ -3,7 +3,7 @@ import Notification from './Notification' interface Input { orgId: string orgName: string - orgPicture?: string + orgPicture?: string | null userId: string scheduledLockAt: Date } @@ -12,7 +12,7 @@ export default class NotificationTeamsLimitReminder extends Notification { readonly type = 'TEAMS_LIMIT_REMINDER' orgId: string orgName: string - orgPicture?: string + orgPicture?: string | null scheduledLockAt: Date constructor(input: Input) { const {userId, orgId, orgName, orgPicture, scheduledLockAt} = input diff --git a/packages/server/database/types/processTeamsLimitsJob.ts b/packages/server/database/types/processTeamsLimitsJob.ts index 562a9698028..6dfb2b8e8df 100644 --- a/packages/server/database/types/processTeamsLimitsJob.ts +++ b/packages/server/database/types/processTeamsLimitsJob.ts @@ -10,7 +10,7 @@ import ScheduledTeamLimitsJob from './ScheduledTeamLimitsJob' const processTeamsLimitsJob = async (job: ScheduledTeamLimitsJob, dataLoader: DataLoaderWorker) => { const {orgId, type} = job const [organization, orgUsers] = await Promise.all([ - dataLoader.get('organizations').load(orgId), + dataLoader.get('organizations').loadNonNull(orgId), dataLoader.get('organizationUsersByOrgId').load(orgId) ]) const {name: orgName, picture: orgPicture, scheduledLockAt, lockedAt} = organization @@ -28,14 +28,11 @@ const processTeamsLimitsJob = async (job: ScheduledTeamLimitsJob, dataLoader: Da if (type === 'LOCK_ORGANIZATION') { const now = new Date() - await Promise.all([ - getKysely() - .updateTable('Organization') - .set({lockedAt: now}) - .where('id', '=', 'orgId') - .execute(), - r.table('Organization').get(orgId).update({lockedAt: now}).run() - ]) + await getKysely() + .updateTable('Organization') + .set({lockedAt: now}) + .where('id', '=', 'orgId') + .execute() organization.lockedAt = lockedAt } else if (type === 'WARN_ORGANIZATION') { const notificationsToInsert = billingLeadersIds.map((userId) => { diff --git a/packages/server/dataloader/__tests__/isOrgVerified.test.ts b/packages/server/dataloader/__tests__/isOrgVerified.test.ts index 58247c515ab..d75386eeaa1 100644 --- a/packages/server/dataloader/__tests__/isOrgVerified.test.ts +++ b/packages/server/dataloader/__tests__/isOrgVerified.test.ts @@ -1,10 +1,12 @@ /* eslint-env jest */ +import {sql} from 'kysely' import {r} from 'rethinkdb-ts' import getRethinkConfig from '../../database/getRethinkConfig' import getRethink from '../../database/rethinkDriver' import OrganizationUser from '../../database/types/OrganizationUser' import generateUID from '../../generateUID' import {DataLoaderWorker} from '../../graphql/graphql' +import getKysely from '../../postgres/getKysely' import getRedis from '../../utils/getRedis' import isUserVerified from '../../utils/isUserVerified' import RootDataLoader from '../RootDataLoader' @@ -28,6 +30,15 @@ const testConfig = { db: TEST_DB } +export const createPGTables = async (...tables: string[]) => { + const pg = getKysely() + return tables.map((table) => { + sql`CREATE TABLE ${sql.table(table)} (LIKE "public".${sql.table(table)} INCLUDING ALL);`.execute( + pg + ) + }) +} + const createTables = async (...tables: string[]) => { for (const tableName of tables) { const structure = await r @@ -75,10 +86,11 @@ const addOrg = async ( members: TestOrganizationUser[], featureFlags?: string[] ) => { - const orgId = activeDomain + const orgId = activeDomain! const org = { id: orgId, activeDomain, + name: 'baddadan', featureFlags: featureFlags ?? [] } @@ -92,7 +104,8 @@ const addOrg = async ( removedAt: member.removedAt ?? null })) - await r.table('Organization').insert(org).run() + const pg = getKysely() + await pg.insertInto('Organization').values(org).execute() await r.table('OrganizationUser').insert(orgUsers).run() const users = orgUsers.map(({userId, domain}) => ({ @@ -110,17 +123,24 @@ const isOrgVerifiedLoader = isOrgVerified(dataLoader as any as RootDataLoader) beforeAll(async () => { await r.connectPool(testConfig) + const pg = getKysely() + try { await r.dbDrop(TEST_DB).run() } catch (e) { //ignore } + await pg.schema.createSchema(TEST_DB).ifNotExists().execute() + sql`SET search_path TO '${TEST_DB}'`.execute(pg) + await r.dbCreate(TEST_DB).run() - await createTables('Organization', 'OrganizationUser') + await createPGTables('Organization') + await createTables('OrganizationUser') }) afterEach(async () => { - await r.table('Organization').delete().run() + const pg = getKysely() + await sql`truncate table ${sql.table('Organization')}`.execute(pg) await r.table('OrganizationUser').delete().run() isOrgVerifiedLoader.clearAll() }) diff --git a/packages/server/dataloader/customLoaderMakers.ts b/packages/server/dataloader/customLoaderMakers.ts index 1743d3959d9..f06aecc3ebe 100644 --- a/packages/server/dataloader/customLoaderMakers.ts +++ b/packages/server/dataloader/customLoaderMakers.ts @@ -6,13 +6,13 @@ import getRethink, {RethinkSchema} from '../database/rethinkDriver' import {RDatum} from '../database/stricterR' import MeetingSettingsTeamPrompt from '../database/types/MeetingSettingsTeamPrompt' import MeetingTemplate from '../database/types/MeetingTemplate' -import Organization from '../database/types/Organization' import OrganizationUser from '../database/types/OrganizationUser' import {Reactable, ReactableEnum} from '../database/types/Reactable' import Task, {TaskStatusEnum} from '../database/types/Task' import getFileStoreManager from '../fileStorage/getFileStoreManager' import isValid from '../graphql/isValid' import {SAMLSource} from '../graphql/public/types/SAML' +import {TeamSource} from '../graphql/public/types/Team' import getKysely from '../postgres/getKysely' import {TeamMeetingTemplate} from '../postgres/pg.d' import {IGetLatestTaskEstimatesQueryResult} from '../postgres/queries/generated/getLatestTaskEstimatesQuery' @@ -29,7 +29,6 @@ import getLatestTaskEstimates from '../postgres/queries/getLatestTaskEstimates' import getMeetingTaskEstimates, { MeetingTaskEstimatesResult } from '../postgres/queries/getMeetingTaskEstimates' -import {Team} from '../postgres/queries/getTeamsByIds' import {AnyMeeting, MeetingTypeEnum} from '../postgres/types/Meeting' import {Logger} from '../utils/Logger' import getRedis from '../utils/getRedis' @@ -38,6 +37,7 @@ import NullableDataLoader from './NullableDataLoader' import RootDataLoader from './RootDataLoader' import normalizeArrayResults from './normalizeArrayResults' import normalizeResults from './normalizeResults' +import {selectTeams} from './primaryKeyLoaderMakers' export interface MeetingSettingsKey { teamId: string @@ -736,57 +736,23 @@ export const samlByOrgId = (parent: RootDataLoader) => { ) } -type OrgWithFounderAndLeads = Organization & { - founder: OrganizationUser | null - billingLeads: OrganizationUser[] -} - // Check if the org has a founder or billing lead with a verified email and their email domain is the same as the org domain export const isOrgVerified = (parent: RootDataLoader) => { return new DataLoader( async (orgIds) => { - const r = await getRethink() - const orgs: OrgWithFounderAndLeads[] = await r - .table('Organization') - .getAll(r.args(orgIds)) - .merge((org: RDatum) => ({ - members: r - .table('OrganizationUser') - .getAll(org('id'), {index: 'orgId'}) - .orderBy('joinedAt') - .coerceTo('array') - })) - .merge((org: RDatum) => ({ - founder: org('members').nth(0).default(null), - billingLeads: org('members') - .filter({inactive: false}) - .filter((row: RDatum) => r.expr(['BILLING_LEADER', 'ORG_ADMIN']).contains(row('role'))) - })) - .run() - - const userIds = orgs - .flatMap((org) => [ - org.founder ? org.founder.userId : null, - ...org.billingLeads.map((lead) => lead.userId) - ]) - .filter((id): id is string => Boolean(id)) - - const users = (await parent.get('users').loadMany(userIds)).filter(isValid) - - return orgIds.map((orgId) => { - const isValid = orgs.some((org) => { - if (org.id !== orgId) return false - const checkEmailDomain = (userId: string) => { - const user = users.find((user) => user.id === userId) - if (!user) return false - return isUserVerified(user) && user.domain === org.activeDomain - } - return [org.founder, ...org.billingLeads].some( - (orgUser) => orgUser && !orgUser.inactive && checkEmailDomain(orgUser.userId) - ) - }) - return isValid - }) + const orgUsersRes = await parent.get('organizationUsersByOrgId').loadMany(orgIds) + const orgUsersWithRole = orgUsersRes + .filter(isValid) + .flat() + .filter(({role}) => role && ['BILLING_LEADER', 'ORG_ADMIN'].includes(role)) + + const orgUsersUserIds = orgUsersWithRole.map((orgUser) => orgUser.userId) + const usersRes = await parent.get('users').loadMany(orgUsersUserIds) + const verifiedUsers = usersRes.filter(isValid).filter(isUserVerified) + const verifiedOrgUsers = orgUsersWithRole.filter((orgUser) => + verifiedUsers.some((user) => user.id === orgUser.userId) + ) + return orgIds.map((orgId) => verifiedOrgUsers.some((orgUser) => orgUser.orgId === orgId)) }, { ...parent.dataLoaderOptions @@ -795,23 +761,20 @@ export const isOrgVerified = (parent: RootDataLoader) => { } export const autoJoinTeamsByOrgId = (parent: RootDataLoader) => { - return new DataLoader( + return new DataLoader( async (orgIds) => { const verificationResults = await parent.get('isOrgVerified').loadMany(orgIds) const verifiedOrgIds = orgIds.filter((_, index) => verificationResults[index]) - const pg = getKysely() - const teams = verifiedOrgIds.length === 0 ? [] - : ((await pg - .selectFrom('Team') + : await selectTeams() .where('orgId', 'in', verifiedOrgIds) .where('autoJoin', '=', true) .where('isArchived', '!=', true) .selectAll() - .execute()) as unknown as Team[]) + .execute() return orgIds.map((orgId) => teams.filter((team) => team.orgId === orgId)) }, diff --git a/packages/server/dataloader/foreignKeyLoaderMakers.ts b/packages/server/dataloader/foreignKeyLoaderMakers.ts index 9bfccba0e94..543647daedb 100644 --- a/packages/server/dataloader/foreignKeyLoaderMakers.ts +++ b/packages/server/dataloader/foreignKeyLoaderMakers.ts @@ -1,10 +1,9 @@ import getKysely from '../postgres/getKysely' -import getTeamsByOrgIds from '../postgres/queries/getTeamsByOrgIds' import {foreignKeyLoaderMaker} from './foreignKeyLoaderMaker' -import {selectRetroReflections} from './primaryKeyLoaderMakers' +import {selectOrganizations, selectRetroReflections, selectTeams} from './primaryKeyLoaderMakers' export const teamsByOrgIds = foreignKeyLoaderMaker('teams', 'orgId', (orgIds) => - getTeamsByOrgIds(orgIds, {isArchived: false}) + selectTeams().where('orgId', 'in', orgIds).where('isArchived', '=', false).execute() ) export const discussionsByMeetingId = foreignKeyLoaderMaker( @@ -74,3 +73,11 @@ export const timelineEventsByMeetingId = foreignKeyLoaderMaker( .execute() } ) + +export const organizationsByActiveDomain = foreignKeyLoaderMaker( + 'organizations', + 'activeDomain', + async (activeDomains) => { + return selectOrganizations().where('activeDomain', 'in', activeDomains).execute() + } +) diff --git a/packages/server/dataloader/primaryKeyLoaderMakers.ts b/packages/server/dataloader/primaryKeyLoaderMakers.ts index dcb6eb8ac0a..dd10a3f0d75 100644 --- a/packages/server/dataloader/primaryKeyLoaderMakers.ts +++ b/packages/server/dataloader/primaryKeyLoaderMakers.ts @@ -4,14 +4,54 @@ import {getDomainJoinRequestsByIds} from '../postgres/queries/getDomainJoinReque import getMeetingSeriesByIds from '../postgres/queries/getMeetingSeriesByIds' import getMeetingTemplatesByIds from '../postgres/queries/getMeetingTemplatesByIds' import {getTeamPromptResponsesByIds} from '../postgres/queries/getTeamPromptResponsesByIds' -import getTeamsByIds from '../postgres/queries/getTeamsByIds' import getTemplateRefsByIds from '../postgres/queries/getTemplateRefsByIds' import getTemplateScaleRefsByIds from '../postgres/queries/getTemplateScaleRefsByIds' import {getUsersByIds} from '../postgres/queries/getUsersByIds' import {primaryKeyLoaderMaker} from './primaryKeyLoaderMaker' export const users = primaryKeyLoaderMaker(getUsersByIds) -export const teams = primaryKeyLoaderMaker(getTeamsByIds) + +export const selectTeams = () => + getKysely() + .selectFrom('Team') + .select([ + 'autoJoin', + 'createdAt', + 'createdBy', + 'id', + 'insightsUpdatedAt', + 'isArchived', + 'isOnboardTeam', + 'isPaid', + 'kudosEmojiUnicode', + 'lastMeetingType', + 'lockMessageHTML', + 'meetingEngagement', + 'mostUsedEmojis', + 'name', + 'orgId', + 'qualAIMeetingsCount', + 'tier', + 'topRetroTemplates', + 'trialStartDate', + 'updatedAt' + ]) + .select(({fn}) => [ + fn< + { + dimensionName: string + cloudId: string + projectKey: string + issueKey: string + fieldName: string + fieldType: string + fieldId: string + }[] + >('to_json', ['jiraDimensionFields']).as('jiraDimensionFields') + ]) +export const teams = primaryKeyLoaderMaker((ids: readonly string[]) => { + return selectTeams().where('id', 'in', ids).execute() +}) export const discussions = primaryKeyLoaderMaker(getDiscussionsByIds) export const templateRefs = primaryKeyLoaderMaker(getTemplateRefsByIds) export const templateScaleRefs = primaryKeyLoaderMaker(getTemplateScaleRefsByIds) @@ -57,3 +97,37 @@ export const retroReflections = primaryKeyLoaderMaker((ids: readonly string[]) = export const timelineEvents = primaryKeyLoaderMaker((ids: readonly string[]) => { return getKysely().selectFrom('TimelineEvent').selectAll().where('id', 'in', ids).execute() }) + +export const selectOrganizations = () => + getKysely() + .selectFrom('Organization') + .select([ + 'id', + 'activeDomain', + 'isActiveDomainTouched', + 'createdAt', + 'name', + 'payLaterClickCount', + 'periodEnd', + 'periodStart', + 'picture', + 'showConversionModal', + 'stripeId', + 'stripeSubscriptionId', + 'upcomingInvoiceEmailSentAt', + 'tier', + 'tierLimitExceededAt', + 'trialStartDate', + 'scheduledLockAt', + 'lockedAt', + 'updatedAt', + 'featureFlags' + ]) + .select(({fn}) => [ + fn<{brand: string; expiry: string; last4: number} | null>('to_json', ['creditCard']).as( + 'creditCard' + ) + ]) +export const organizations = primaryKeyLoaderMaker((ids: readonly string[]) => { + return selectOrganizations().where('id', 'in', ids).execute() +}) diff --git a/packages/server/dataloader/rethinkForeignKeyLoaderMakers.ts b/packages/server/dataloader/rethinkForeignKeyLoaderMakers.ts index 461c58dd606..4385bb32fd9 100644 --- a/packages/server/dataloader/rethinkForeignKeyLoaderMakers.ts +++ b/packages/server/dataloader/rethinkForeignKeyLoaderMakers.ts @@ -116,14 +116,6 @@ export const meetingMembersByUserId = new RethinkForeignKeyLoaderMaker( } ) -export const organizationsByActiveDomain = new RethinkForeignKeyLoaderMaker( - 'organizations', - 'activeDomain', - async (activeDomains) => { - const r = await getRethink() - return r.table('Organization').getAll(r.args(activeDomains), {index: 'activeDomain'}).run() - } -) export const organizationUsersByOrgId = new RethinkForeignKeyLoaderMaker( 'organizationUsers', 'orgId', diff --git a/packages/server/dataloader/rethinkPrimaryKeyLoaderMakers.ts b/packages/server/dataloader/rethinkPrimaryKeyLoaderMakers.ts index d5dbd237079..78a198aa9b9 100644 --- a/packages/server/dataloader/rethinkPrimaryKeyLoaderMakers.ts +++ b/packages/server/dataloader/rethinkPrimaryKeyLoaderMakers.ts @@ -13,7 +13,6 @@ export const meetingMembers = new RethinkPrimaryKeyLoaderMaker('MeetingMember') export const newMeetings = new RethinkPrimaryKeyLoaderMaker('NewMeeting') export const newFeatures = new RethinkPrimaryKeyLoaderMaker('NewFeature') export const notifications = new RethinkPrimaryKeyLoaderMaker('Notification') -export const organizations = new RethinkPrimaryKeyLoaderMaker('Organization') export const organizationUsers = new RethinkPrimaryKeyLoaderMaker('OrganizationUser') export const templateScales = new RethinkPrimaryKeyLoaderMaker('TemplateScale') export const slackAuths = new RethinkPrimaryKeyLoaderMaker('SlackAuth') diff --git a/packages/server/email/UpcomingInvoiceEmailTemplate.tsx b/packages/server/email/UpcomingInvoiceEmailTemplate.tsx deleted file mode 100644 index 9e9f765fc2a..00000000000 --- a/packages/server/email/UpcomingInvoiceEmailTemplate.tsx +++ /dev/null @@ -1,37 +0,0 @@ -import Oy from 'oy-vey' -import UpcomingInvoiceEmail, { - UpcomingInvoiceEmailProps -} from 'parabol-client/modules/email/components/UpcomingInvoiceEmail' -import {headCSS} from 'parabol-client/modules/email/styles' -import React from 'react' - -const subject = 'Your monthly summary' - -export const makeBody = (props: UpcomingInvoiceEmailProps) => { - const {periodEndStr, newUsers, memberUrl} = props - const newUserBullets = newUsers.reduce( - (str, newUser) => str + `* ${newUser.name} (${newUser.email})\n`, - '' - ) - return ` -Hello, - -Your teams have added the following users to your organization for the billing cycle ending on ${periodEndStr}: -${newUserBullets} - -If any of these users were added by mistake, simply remove them under Organization Settings: ${memberUrl} - -Your friends, -The Parabol Product Team -` -} - -export default (props: UpcomingInvoiceEmailProps) => ({ - subject, - body: makeBody(props), - html: Oy.renderTemplate(, { - headCSS, - title: subject, - previewText: subject - }) -}) diff --git a/packages/server/graphql/mutations/addTeam.ts b/packages/server/graphql/mutations/addTeam.ts index 3b9fb4f5bdb..2663a74cf26 100644 --- a/packages/server/graphql/mutations/addTeam.ts +++ b/packages/server/graphql/mutations/addTeam.ts @@ -55,7 +55,7 @@ export default { // VALIDATION const [orgTeams, organization, viewer] = await Promise.all([ getTeamsByOrgIds([orgId], {isArchived: false}), - dataLoader.get('organizations').load(orgId), + dataLoader.get('organizations').loadNonNull(orgId), dataLoader.get('users').loadNonNull(viewerId) ]) const orgTeamNames = orgTeams.map((team) => team.name) @@ -74,7 +74,7 @@ export default { return standardError(new Error('Failed input validation'), {userId: viewerId}) } if (orgTeams.length >= Threshold.MAX_FREE_TEAMS) { - const organization = await dataLoader.get('organizations').load(orgId) + const organization = await dataLoader.get('organizations').loadNonNull(orgId) if (getFeatureTier(organization) === 'starter') { return standardError(new Error('Max free teams reached'), {userId: viewerId}) } diff --git a/packages/server/graphql/mutations/archiveOrganization.ts b/packages/server/graphql/mutations/archiveOrganization.ts index 474d9d45fc4..9c998e49810 100644 --- a/packages/server/graphql/mutations/archiveOrganization.ts +++ b/packages/server/graphql/mutations/archiveOrganization.ts @@ -41,7 +41,7 @@ export default { } const [organization, viewer] = await Promise.all([ - dataLoader.get('organizations').load(orgId), + dataLoader.get('organizations').loadNonNull(orgId), dataLoader.get('users').loadNonNull(viewerId) ]) const {tier} = organization diff --git a/packages/server/graphql/mutations/downgradeToStarter.ts b/packages/server/graphql/mutations/downgradeToStarter.ts index bbed44fd08c..dc91b9f6a60 100644 --- a/packages/server/graphql/mutations/downgradeToStarter.ts +++ b/packages/server/graphql/mutations/downgradeToStarter.ts @@ -54,7 +54,7 @@ export default { return standardError(new Error('Other tool name is too long'), {userId: viewerId}) } - const {stripeSubscriptionId, tier} = await dataLoader.get('organizations').load(orgId) + const {stripeSubscriptionId, tier} = await dataLoader.get('organizations').loadNonNull(orgId) dataLoader.get('organizations').clear(orgId) if (tier === 'starter') { diff --git a/packages/server/graphql/mutations/helpers/canAccessAISummary.ts b/packages/server/graphql/mutations/helpers/canAccessAISummary.ts index a3fdb939d46..4ba56c793fc 100644 --- a/packages/server/graphql/mutations/helpers/canAccessAISummary.ts +++ b/packages/server/graphql/mutations/helpers/canAccessAISummary.ts @@ -1,17 +1,17 @@ import {Threshold} from 'parabol-client/types/constEnums' -import {Team} from '../../../postgres/queries/getTeamsByIds' import {DataLoaderWorker} from '../../graphql' +import {TeamSource} from '../../public/types/Team' import {getFeatureTier} from '../../types/helpers/getFeatureTier' const canAccessAISummary = async ( - team: Team, + team: TeamSource, featureFlags: string[], dataLoader: DataLoaderWorker, meetingType: 'standup' | 'retrospective' ) => { if (featureFlags.includes('noAISummary') || !team) return false const {qualAIMeetingsCount, orgId} = team - const organization = await dataLoader.get('organizations').load(orgId) + const organization = await dataLoader.get('organizations').loadNonNull(orgId) if (organization.featureFlags?.includes('noAISummary')) return false if (meetingType === 'standup') { if (!organization.featureFlags?.includes('standupAISummary')) return false diff --git a/packages/server/graphql/mutations/helpers/createNewOrg.ts b/packages/server/graphql/mutations/helpers/createNewOrg.ts index b5fa95ea827..bbfeec1f9d1 100644 --- a/packages/server/graphql/mutations/helpers/createNewOrg.ts +++ b/packages/server/graphql/mutations/helpers/createNewOrg.ts @@ -33,8 +33,5 @@ export default async function createNewOrg( .insertInto('Organization') .values({...org, creditCard: null}) .execute() - return r({ - org: r.table('Organization').insert(org), - organizationUser: r.table('OrganizationUser').insert(orgUser) - }).run() + await r.table('OrganizationUser').insert(orgUser).run() } diff --git a/packages/server/graphql/mutations/helpers/createTeamAndLeader.ts b/packages/server/graphql/mutations/helpers/createTeamAndLeader.ts index 1019dd0b789..7452113d85d 100644 --- a/packages/server/graphql/mutations/helpers/createTeamAndLeader.ts +++ b/packages/server/graphql/mutations/helpers/createTeamAndLeader.ts @@ -26,7 +26,7 @@ export default async function createTeamAndLeader( const r = await getRethink() const {id: userId} = user const {id: teamId, orgId} = newTeam - const organization = await dataLoader.get('organizations').load(orgId) + const organization = await dataLoader.get('organizations').loadNonNull(orgId) const {tier, trialStartDate} = organization const verifiedTeam = new Team({...newTeam, createdBy: userId, tier, trialStartDate}) const meetingSettings = [ diff --git a/packages/server/graphql/mutations/helpers/endMeeting/sendNewMeetingSummary.ts b/packages/server/graphql/mutations/helpers/endMeeting/sendNewMeetingSummary.ts index 804b50a4849..1b22cefe1b4 100644 --- a/packages/server/graphql/mutations/helpers/endMeeting/sendNewMeetingSummary.ts +++ b/packages/server/graphql/mutations/helpers/endMeeting/sendNewMeetingSummary.ts @@ -24,7 +24,7 @@ export default async function sendNewMeetingSummary( const [content, users, organization] = await Promise.all([ newMeetingSummaryEmailCreator({meetingId, context}), dataLoader.get('users').loadMany(userIds), - dataLoader.get('organizations').load(orgId) + dataLoader.get('organizations').loadNonNull(orgId) ]) const {tier, name: orgName} = organization const emailAddresses = users diff --git a/packages/server/graphql/mutations/helpers/generateGroups.ts b/packages/server/graphql/mutations/helpers/generateGroups.ts index a7e80d060a3..d08a1922f8f 100644 --- a/packages/server/graphql/mutations/helpers/generateGroups.ts +++ b/packages/server/graphql/mutations/helpers/generateGroups.ts @@ -16,7 +16,7 @@ const generateGroups = async ( if (reflections.length === 0) return const {meetingId} = reflections[0]! const team = await dataLoader.get('teams').loadNonNull(teamId) - const organization = await dataLoader.get('organizations').load(team.orgId) + const organization = await dataLoader.get('organizations').loadNonNull(team.orgId) const {featureFlags} = organization const hasSuggestGroupsFlag = featureFlags?.includes('suggestGroups') if (!hasSuggestGroupsFlag) return diff --git a/packages/server/graphql/mutations/helpers/hideConversionModal.ts b/packages/server/graphql/mutations/helpers/hideConversionModal.ts index 3e186459c44..df578c85a2c 100644 --- a/packages/server/graphql/mutations/helpers/hideConversionModal.ts +++ b/packages/server/graphql/mutations/helpers/hideConversionModal.ts @@ -4,7 +4,7 @@ import errorFilter from '../../errorFilter' import {DataLoaderWorker} from '../../graphql' const hideConversionModal = async (orgId: string, dataLoader: DataLoaderWorker) => { - const organization = await dataLoader.get('organizations').load(orgId) + const organization = await dataLoader.get('organizations').loadNonNull(orgId) const {showConversionModal} = organization if (showConversionModal) { const r = await getRethink() @@ -13,13 +13,6 @@ const hideConversionModal = async (orgId: string, dataLoader: DataLoaderWorker) .set({showConversionModal: false}) .where('id', '=', orgId) .execute() - await r - .table('Organization') - .get(orgId) - .update({ - showConversionModal: false - }) - .run() organization.showConversionModal = false const teams = await dataLoader.get('teamsByOrgIds').load(orgId) const teamIds = teams.map(({id}) => id) diff --git a/packages/server/graphql/mutations/helpers/inviteToTeamHelper.ts b/packages/server/graphql/mutations/helpers/inviteToTeamHelper.ts index 423e3d980c2..916faae658a 100644 --- a/packages/server/graphql/mutations/helpers/inviteToTeamHelper.ts +++ b/packages/server/graphql/mutations/helpers/inviteToTeamHelper.ts @@ -87,7 +87,7 @@ const inviteToTeamHelper = async ( } const {name: teamName, createdAt, isOnboardTeam, orgId} = team - const organization = await dataLoader.get('organizations').load(orgId) + const organization = await dataLoader.get('organizations').loadNonNull(orgId) const {tier, name: orgName} = organization const uniqueInvitees = Array.from(new Set(validInvitees)) // filter out emails already on team diff --git a/packages/server/graphql/mutations/helpers/isStartMeetingLocked.ts b/packages/server/graphql/mutations/helpers/isStartMeetingLocked.ts index e7b55bf3dd4..75e2d2f3c84 100644 --- a/packages/server/graphql/mutations/helpers/isStartMeetingLocked.ts +++ b/packages/server/graphql/mutations/helpers/isStartMeetingLocked.ts @@ -3,7 +3,7 @@ import {DataLoaderWorker} from '../../graphql' const isStartMeetingLocked = async (teamId: string, dataLoader: DataLoaderWorker) => { const team = await dataLoader.get('teams').loadNonNull(teamId) - const organization = await dataLoader.get('organizations').load(team.orgId) + const organization = await dataLoader.get('organizations').loadNonNull(team.orgId) const {lockedAt: organizationLockedAt, name: organizationName} = organization const {isPaid, lockMessageHTML} = team diff --git a/packages/server/graphql/mutations/helpers/notifications/MSTeamsNotifier.ts b/packages/server/graphql/mutations/helpers/notifications/MSTeamsNotifier.ts index 23159529393..7ca953ab19c 100644 --- a/packages/server/graphql/mutations/helpers/notifications/MSTeamsNotifier.ts +++ b/packages/server/graphql/mutations/helpers/notifications/MSTeamsNotifier.ts @@ -6,13 +6,13 @@ import appOrigin from '../../../../appOrigin' import Meeting from '../../../../database/types/Meeting' import {SlackNotificationEventEnum as EventEnum} from '../../../../database/types/SlackNotification' import {IntegrationProviderMSTeams} from '../../../../postgres/queries/getIntegrationProvidersByIds' -import {Team} from '../../../../postgres/queries/getTeamsByIds' import IUser from '../../../../postgres/types/IUser' import {MeetingTypeEnum} from '../../../../postgres/types/Meeting' import MSTeamsServerManager from '../../../../utils/MSTeamsServerManager' import {analytics} from '../../../../utils/analytics/analytics' import sendToSentry from '../../../../utils/sendToSentry' import {DataLoaderWorker} from '../../../graphql' +import {TeamSource} from '../../../public/types/Team' import {NotificationIntegrationHelper} from './NotificationIntegrationHelper' import {createNotifier} from './Notifier' import getSummaryText from './getSummaryText' @@ -359,7 +359,7 @@ function GenerateACMeetingTitle(meetingTitle: string) { return titleTextBlock } -function GenerateACMeetingAndTeamsDetails(team: Team, meeting: Meeting) { +function GenerateACMeetingAndTeamsDetails(team: TeamSource, meeting: Meeting) { const meetingDetailColumnSet = new AdaptiveCards.ColumnSet() const teamDetailColumn = new AdaptiveCards.Column() teamDetailColumn.width = 'stretch' diff --git a/packages/server/graphql/mutations/helpers/notifications/MattermostNotifier.ts b/packages/server/graphql/mutations/helpers/notifications/MattermostNotifier.ts index 3eadfa37b7d..9598dac6f1b 100644 --- a/packages/server/graphql/mutations/helpers/notifications/MattermostNotifier.ts +++ b/packages/server/graphql/mutations/helpers/notifications/MattermostNotifier.ts @@ -7,7 +7,6 @@ import appOrigin from '../../../../appOrigin' import Meeting from '../../../../database/types/Meeting' import {SlackNotificationEventEnum as EventEnum} from '../../../../database/types/SlackNotification' import {IntegrationProviderMattermost} from '../../../../postgres/queries/getIntegrationProvidersByIds' -import {Team} from '../../../../postgres/queries/getTeamsByIds' import IUser from '../../../../postgres/types/IUser' import {MeetingTypeEnum} from '../../../../postgres/types/Meeting' import MattermostServerManager from '../../../../utils/MattermostServerManager' @@ -15,6 +14,7 @@ import {analytics} from '../../../../utils/analytics/analytics' import {toEpochSeconds} from '../../../../utils/epochTime' import sendToSentry from '../../../../utils/sendToSentry' import {DataLoaderWorker} from '../../../graphql' +import {TeamSource} from '../../../public/types/Team' import {NotificationIntegrationHelper} from './NotificationIntegrationHelper' import {createNotifier} from './Notifier' import getSummaryText from './getSummaryText' @@ -92,7 +92,7 @@ const makeEndMeetingButtons = (meeting: Meeting) => { type MattermostNotificationAuth = IntegrationProviderMattermost & {userId: string} const makeTeamPromptStartMeetingNotification = ( - team: Team, + team: TeamSource, meeting: Meeting, meetingUrl: string ) => { @@ -118,7 +118,11 @@ const makeTeamPromptStartMeetingNotification = ( ] } -const makeGenericStartMeetingNotification = (team: Team, meeting: Meeting, meetingUrl: string) => { +const makeGenericStartMeetingNotification = ( + team: TeamSource, + meeting: Meeting, + meetingUrl: string +) => { return [ makeFieldsAttachment( [ @@ -148,7 +152,11 @@ const makeGenericStartMeetingNotification = (team: Team, meeting: Meeting, meeti const makeStartMeetingNotificationLookup: Record< MeetingTypeEnum, - (team: Team, meeting: Meeting, meetingUrl: string) => ReturnType[] + ( + team: TeamSource, + meeting: Meeting, + meetingUrl: string + ) => ReturnType[] > = { teamPrompt: makeTeamPromptStartMeetingNotification, action: makeGenericStartMeetingNotification, diff --git a/packages/server/graphql/mutations/helpers/notifications/NotificationIntegrationHelper.ts b/packages/server/graphql/mutations/helpers/notifications/NotificationIntegrationHelper.ts index 153f0b4d645..7dd24c4c78c 100644 --- a/packages/server/graphql/mutations/helpers/notifications/NotificationIntegrationHelper.ts +++ b/packages/server/graphql/mutations/helpers/notifications/NotificationIntegrationHelper.ts @@ -1,7 +1,7 @@ import Meeting from '../../../../database/types/Meeting' import {TeamPromptResponse} from '../../../../postgres/queries/getTeamPromptResponsesByIds' -import {Team} from '../../../../postgres/queries/getTeamsByIds' import User from '../../../../postgres/types/IUser' +import {TeamSource} from '../../../public/types/Team' export type NotifyResponse = | 'success' @@ -12,25 +12,25 @@ export type NotifyResponse = } export type NotificationIntegration = { - startMeeting(meeting: Meeting, team: Team, user: User): Promise - updateMeeting?(meeting: Meeting, team: Team, user: User): Promise + startMeeting(meeting: Meeting, team: TeamSource, user: User): Promise + updateMeeting?(meeting: Meeting, team: TeamSource, user: User): Promise endMeeting( meeting: Meeting, - team: Team, + team: TeamSource, user: User, standupResponses: {user: User; response: TeamPromptResponse}[] | null ): Promise startTimeLimit( scheduledEndTime: Date, meeting: Meeting, - team: Team, + team: TeamSource, user: User ): Promise - endTimeLimit(meeting: Meeting, team: Team, user: User): Promise + endTimeLimit(meeting: Meeting, team: TeamSource, user: User): Promise integrationUpdated(user: User): Promise standupResponseSubmitted( meeting: Meeting, - team: Team, + team: TeamSource, user: User, response: TeamPromptResponse ): Promise diff --git a/packages/server/graphql/mutations/helpers/notifications/SlackNotifier.ts b/packages/server/graphql/mutations/helpers/notifications/SlackNotifier.ts index ff9e282f1a5..37ff8e356cb 100644 --- a/packages/server/graphql/mutations/helpers/notifications/SlackNotifier.ts +++ b/packages/server/graphql/mutations/helpers/notifications/SlackNotifier.ts @@ -12,7 +12,6 @@ import {SlackNotificationEvent} from '../../../../database/types/SlackNotificati import {SlackNotificationAuth} from '../../../../dataloader/integrationAuthLoaders' import {TeamPromptResponse} from '../../../../postgres/queries/getTeamPromptResponsesByIds' import {getTeamPromptResponsesByMeetingId} from '../../../../postgres/queries/getTeamPromptResponsesByMeetingIds' -import {Team} from '../../../../postgres/queries/getTeamsByIds' import User from '../../../../postgres/types/IUser' import {AnyMeeting, MeetingTypeEnum} from '../../../../postgres/types/Meeting' import SlackServerManager from '../../../../utils/SlackServerManager' @@ -21,6 +20,7 @@ import {toEpochSeconds} from '../../../../utils/epochTime' import sendToSentry from '../../../../utils/sendToSentry' import {convertToMarkdown} from '../../../../utils/tiptap/convertToMarkdown' import {DataLoaderWorker} from '../../../graphql' +import {TeamSource} from '../../../public/types/Team' import {NotificationIntegrationHelper} from './NotificationIntegrationHelper' import {createNotifier} from './Notifier' import getSummaryText from './getSummaryText' @@ -137,12 +137,12 @@ const makeEndMeetingButtons = (meeting: Meeting) => { } } -const createTeamSectionContent = (team: Team) => `*Team:*\n${team.name}` +const createTeamSectionContent = (team: TeamSource) => `*Team:*\n${team.name}` const createMeetingSectionContent = (meeting: Meeting) => `*Meeting:*\n${meeting.name}` const makeTeamPromptStartMeetingNotification = ( - team: Team, + team: TeamSource, meeting: Meeting, meetingUrl: string ): SlackNotification => { @@ -157,7 +157,7 @@ const makeTeamPromptStartMeetingNotification = ( } const makeGenericStartMeetingNotification = ( - team: Team, + team: TeamSource, meeting: Meeting, meetingUrl: string ): SlackNotification => { @@ -173,7 +173,7 @@ const makeGenericStartMeetingNotification = ( const makeStartMeetingNotificationLookup: Record< MeetingTypeEnum, - (team: Team, meeting: Meeting, meetingUrl: string) => SlackNotification + (team: TeamSource, meeting: Meeting, meetingUrl: string) => SlackNotification > = { teamPrompt: makeTeamPromptStartMeetingNotification, action: makeGenericStartMeetingNotification, @@ -184,7 +184,7 @@ const makeStartMeetingNotificationLookup: Record< const addStandupResponsesToThread = async ( res: PostMessageResponse, standupResponses: Array<{user: User; response: TeamPromptResponse}> | null, - team: Team, + team: TeamSource, user: User, meeting: Meeting, notificationChannel: NotificationChannel diff --git a/packages/server/graphql/mutations/helpers/oldUpgradeToTeamTier.ts b/packages/server/graphql/mutations/helpers/oldUpgradeToTeamTier.ts index 5e998500973..24c2ed3f6bc 100644 --- a/packages/server/graphql/mutations/helpers/oldUpgradeToTeamTier.ts +++ b/packages/server/graphql/mutations/helpers/oldUpgradeToTeamTier.ts @@ -1,6 +1,7 @@ import removeTeamsLimitObjects from '../../../billing/helpers/removeTeamsLimitObjects' import getRethink from '../../../database/rethinkDriver' import getKysely from '../../../postgres/getKysely' +import {toCreditCard} from '../../../postgres/helpers/toCreditCard' import {fromEpochSeconds} from '../../../utils/epochTime' import setTierForOrgUsers from '../../../utils/setTierForOrgUsers' import setUserTierForOrgId from '../../../utils/setUserTierForOrgId' @@ -18,7 +19,7 @@ const oldUpgradeToTeamTier = async ( const pg = getKysely() const now = new Date() - const organization = await r.table('Organization').get(orgId).run() + const organization = await dataLoader.get('organizations').load(orgId) if (!organization) throw new Error('Bad orgId') const {stripeId, stripeSubscriptionId} = organization @@ -44,22 +45,22 @@ const oldUpgradeToTeamTier = async ( } } - await r({ - updatedOrg: r - .table('Organization') - .get(orgId) - .update({ - ...subscriptionFields, - creditCard: await getCCFromCustomer(customer), - tier: 'team', - stripeId: customer.id, - tierLimitExceededAt: null, - scheduledLockAt: null, - lockedAt: null, - updatedAt: now, - trialStartDate: null - }) - }).run() + const creditCard = await getCCFromCustomer(customer) + await getKysely() + .updateTable('Organization') + .set({ + ...subscriptionFields, + creditCard: toCreditCard(creditCard), + tier: 'team', + stripeId: customer.id, + tierLimitExceededAt: null, + scheduledLockAt: null, + lockedAt: null, + updatedAt: now, + trialStartDate: null + }) + .where('id', '=', orgId) + .execute() // If subscription already exists and has open invoices, try to process them if (stripeSubscriptionId) { diff --git a/packages/server/graphql/mutations/helpers/removeFromOrg.ts b/packages/server/graphql/mutations/helpers/removeFromOrg.ts index ac4fb4ccca5..af706293f0b 100644 --- a/packages/server/graphql/mutations/helpers/removeFromOrg.ts +++ b/packages/server/graphql/mutations/helpers/removeFromOrg.ts @@ -57,7 +57,7 @@ const removeFromOrg = async ( // need to make sure the org doc is updated before adjusting this const {role} = organizationUser if (role && ['BILLING_LEADER', 'ORG_ADMIN'].includes(role)) { - const organization = await dataLoader.get('organizations').load(orgId) + const organization = await dataLoader.get('organizations').loadNonNull(orgId) // if no other billing leader, promote the oldest // if team tier & no other member, downgrade to starter const otherBillingLeaders = await r diff --git a/packages/server/graphql/mutations/helpers/resolveDowngradeToStarter.ts b/packages/server/graphql/mutations/helpers/resolveDowngradeToStarter.ts index 803c767e1e7..8e44110824c 100644 --- a/packages/server/graphql/mutations/helpers/resolveDowngradeToStarter.ts +++ b/packages/server/graphql/mutations/helpers/resolveDowngradeToStarter.ts @@ -1,4 +1,3 @@ -import getRethink from '../../../database/rethinkDriver' import {DataLoaderInstance} from '../../../dataloader/RootDataLoader' import getKysely from '../../../postgres/getKysely' import updateTeamByOrgId from '../../../postgres/queries/updateTeamByOrgId' @@ -19,7 +18,6 @@ const resolveDowngradeToStarter = async ( ) => { const now = new Date() const manager = getStripeManager() - const r = await getRethink() const pg = getKysely() try { await manager.deleteSubscription(stripeSubscriptionId) @@ -28,7 +26,7 @@ const resolveDowngradeToStarter = async ( } const [org] = await Promise.all([ - dataLoader.get('organizations').load(orgId), + dataLoader.get('organizations').loadNonNull(orgId), pg .updateTable('Organization') .set({ @@ -43,14 +41,6 @@ const resolveDowngradeToStarter = async ( .set({metadata: null, lastUpdatedBy: user.id}) .where('orgId', '=', orgId) .execute(), - r({ - orgUpdate: r.table('Organization').get(orgId).update({ - tier: 'starter', - periodEnd: now, - stripeSubscriptionId: null, - updatedAt: now - }) - }).run(), updateTeamByOrgId( { tier: 'starter', @@ -63,7 +53,7 @@ const resolveDowngradeToStarter = async ( await Promise.all([setUserTierForOrgId(orgId), setTierForOrgUsers(orgId)]) analytics.organizationDowngraded(user, { orgId, - domain: org.activeDomain, + domain: org.activeDomain || undefined, orgName: org.name, oldTier: 'team', newTier: 'starter', diff --git a/packages/server/graphql/mutations/helpers/safeCreateRetrospective.ts b/packages/server/graphql/mutations/helpers/safeCreateRetrospective.ts index c803811cc56..221557c3b09 100644 --- a/packages/server/graphql/mutations/helpers/safeCreateRetrospective.ts +++ b/packages/server/graphql/mutations/helpers/safeCreateRetrospective.ts @@ -34,7 +34,7 @@ const safeCreateRetrospective = async ( dataLoader.get('teams').loadNonNull(teamId) ]) - const organization = await dataLoader.get('organizations').load(team.orgId) + const organization = await dataLoader.get('organizations').loadNonNull(team.orgId) const {showConversionModal} = organization const meetingId = generateUID() diff --git a/packages/server/graphql/mutations/moveTeamToOrg.ts b/packages/server/graphql/mutations/moveTeamToOrg.ts index bef33207dd1..7e3ea1ad40d 100644 --- a/packages/server/graphql/mutations/moveTeamToOrg.ts +++ b/packages/server/graphql/mutations/moveTeamToOrg.ts @@ -30,7 +30,7 @@ const moveToOrg = async ( const su = isSuperUser(authToken) // VALIDATION const [org, teams, isPaidResult] = await Promise.all([ - dataLoader.get('organizations').load(orgId), + dataLoader.get('organizations').loadNonNull(orgId), getTeamsByIds([teamId]), pg .selectFrom('Team') diff --git a/packages/server/graphql/mutations/oldUpgradeToTeamTier.ts b/packages/server/graphql/mutations/oldUpgradeToTeamTier.ts index f8d28d78b62..ddcd8d27035 100644 --- a/packages/server/graphql/mutations/oldUpgradeToTeamTier.ts +++ b/packages/server/graphql/mutations/oldUpgradeToTeamTier.ts @@ -42,7 +42,7 @@ export default { stripeSubscriptionId: startingSubId, name: orgName, activeDomain: domain - } = await r.table('Organization').get(orgId).run() + } = await dataLoader.get('organizations').loadNonNull(orgId) if (startingSubId) { return standardError(new Error('Already an organization on the team tier'), { @@ -76,7 +76,7 @@ export default { const teamIds = teams.map(({id}) => id) analytics.organizationUpgraded(viewer, { orgId, - domain, + domain: domain || undefined, orgName, oldTier: 'starter', newTier: 'team' diff --git a/packages/server/graphql/mutations/payLater.ts b/packages/server/graphql/mutations/payLater.ts index 49a4b6ae794..18ec12d6af9 100644 --- a/packages/server/graphql/mutations/payLater.ts +++ b/packages/server/graphql/mutations/payLater.ts @@ -1,7 +1,6 @@ import {GraphQLID, GraphQLNonNull} from 'graphql' import {SubscriptionChannel} from 'parabol-client/types/constEnums' import getRethink from '../../database/rethinkDriver' -import {RValue} from '../../database/stricterR' import getKysely from '../../postgres/getKysely' import getPg from '../../postgres/getPg' import {incrementUserPayLaterClickCountQuery} from '../../postgres/queries/generated/incrementUserPayLaterClickCountQuery' @@ -57,13 +56,6 @@ export default { })) .where('id', '=', orgId) .execute() - await r - .table('Organization') - .get(orgId) - .update((row: RValue) => ({ - payLaterClickCount: row('payLaterClickCount').default(0).add(1) - })) - .run() await r .table('NewMeeting') .get(meetingId) diff --git a/packages/server/graphql/private/mutations/backupOrganization.ts b/packages/server/graphql/private/mutations/backupOrganization.ts index cfc321db1ad..c347258c54b 100644 --- a/packages/server/graphql/private/mutations/backupOrganization.ts +++ b/packages/server/graphql/private/mutations/backupOrganization.ts @@ -191,9 +191,6 @@ const backupOrganization: MutationResolvers['backupOrganization'] = async (_sour newMeeting: (r.table('NewMeeting').getAll(r.args(teamIds), {index: 'teamId'}) as any) .coerceTo('array') .do((items: RValue) => r.db(DESTINATION).table('NewMeeting').insert(items)), - organization: (r.table('Organization').getAll(r.args(orgIds)) as any) - .coerceTo('array') - .do((items: RValue) => r.db(DESTINATION).table('Organization').insert(items)), organizationUser: (r.table('OrganizationUser').getAll(r.args(orgIds), {index: 'orgId'}) as any) .coerceTo('array') .do((items: RValue) => r.db(DESTINATION).table('OrganizationUser').insert(items)), diff --git a/packages/server/graphql/private/mutations/changeEmailDomain.ts b/packages/server/graphql/private/mutations/changeEmailDomain.ts index 351bbe85af4..85228837367 100644 --- a/packages/server/graphql/private/mutations/changeEmailDomain.ts +++ b/packages/server/graphql/private/mutations/changeEmailDomain.ts @@ -60,11 +60,6 @@ const changeEmailDomain: MutationResolvers['changeEmailDomain'] = async ( .set({activeDomain: normalizedNewDomain}) .where('activeDomain', '=', normalizedOldDomain) .execute(), - r - .table('Organization') - .filter((row: RDatum) => row('activeDomain').eq(normalizedOldDomain)) - .update({activeDomain: normalizedNewDomain}) - .run(), r .table('TeamMember') .filter((row: RDatum) => row('email').match(`@${normalizedOldDomain}$`)) diff --git a/packages/server/graphql/private/mutations/draftEnterpriseInvoice.ts b/packages/server/graphql/private/mutations/draftEnterpriseInvoice.ts index ab2da48431c..a3f1dfe13f6 100644 --- a/packages/server/graphql/private/mutations/draftEnterpriseInvoice.ts +++ b/packages/server/graphql/private/mutations/draftEnterpriseInvoice.ts @@ -59,7 +59,6 @@ const draftEnterpriseInvoice: MutationResolvers['draftEnterpriseInvoice'] = asyn {orgId, quantity, email, apEmail, plan}, {dataLoader} ) => { - const r = await getRethink() const pg = getKysely() const now = new Date() @@ -107,7 +106,6 @@ const draftEnterpriseInvoice: MutationResolvers['draftEnterpriseInvoice'] = asyn .set({stripeId: customer.id}) .where('id', '=', orgId) .execute() - await r.table('Organization').get(orgId).update({stripeId: customer.id}).run() customerId = customer.id } else { customerId = stripeId @@ -136,22 +134,6 @@ const draftEnterpriseInvoice: MutationResolvers['draftEnterpriseInvoice'] = asyn }) .where('id', '=', orgId) .execute(), - r({ - updatedOrg: r - .table('Organization') - .get(orgId) - .update({ - periodEnd: fromEpochSeconds(subscription.current_period_end), - periodStart: fromEpochSeconds(subscription.current_period_start), - stripeSubscriptionId: subscription.id, - tier: 'enterprise', - tierLimitExceededAt: null, - scheduledLockAt: null, - lockedAt: null, - updatedAt: now, - trialStartDate: null - }) - }).run(), pg .updateTable('Team') .set({ @@ -171,7 +153,7 @@ const draftEnterpriseInvoice: MutationResolvers['draftEnterpriseInvoice'] = asyn ]) analytics.organizationUpgraded(user, { orgId, - domain: org.activeDomain, + domain: org.activeDomain || undefined, orgName: org.name, isTrial: !!org.trialStartDate, oldTier: 'starter', diff --git a/packages/server/graphql/private/mutations/endTrial.ts b/packages/server/graphql/private/mutations/endTrial.ts index 890ffdb0015..eb600c5806d 100644 --- a/packages/server/graphql/private/mutations/endTrial.ts +++ b/packages/server/graphql/private/mutations/endTrial.ts @@ -1,4 +1,3 @@ -import getRethink from '../../../database/rethinkDriver' import getKysely from '../../../postgres/getKysely' import setTierForOrgUsers from '../../../utils/setTierForOrgUsers' import setUserTierForOrgId from '../../../utils/setUserTierForOrgId' @@ -6,12 +5,12 @@ import standardError from '../../../utils/standardError' import {MutationResolvers} from '../resolverTypes' const endTrial: MutationResolvers['endTrial'] = async (_source, {orgId}, {dataLoader}) => { - const now = new Date() - const r = await getRethink() const pg = getKysely() const organization = await dataLoader.get('organizations').load(orgId) - + if (!organization) { + return {error: {message: 'Organization not found'}} + } // VALIDATION if (!organization.trialStartDate) { return standardError(new Error('No trial active for org')) @@ -20,12 +19,6 @@ const endTrial: MutationResolvers['endTrial'] = async (_source, {orgId}, {dataLo // RESOLUTION await Promise.all([ pg.updateTable('Organization').set({trialStartDate: null}).where('id', '=', orgId).execute(), - r({ - orgUpdate: r.table('Organization').get(orgId).update({ - trialStartDate: null, - updatedAt: now - }) - }).run(), pg.updateTable('Team').set({trialStartDate: null}).where('orgId', '=', orgId).execute() ]) @@ -34,7 +27,7 @@ const endTrial: MutationResolvers['endTrial'] = async (_source, {orgId}, {dataLo await Promise.all([setUserTierForOrgId(orgId), setTierForOrgUsers(orgId)]) - return {organization, trialStartDate: initialTrialStartDate} + return {orgId, trialStartDate: initialTrialStartDate} } export default endTrial diff --git a/packages/server/graphql/private/mutations/flagConversionModal.ts b/packages/server/graphql/private/mutations/flagConversionModal.ts index 4ba7bd4b4f1..0ca11d6139f 100644 --- a/packages/server/graphql/private/mutations/flagConversionModal.ts +++ b/packages/server/graphql/private/mutations/flagConversionModal.ts @@ -1,4 +1,3 @@ -import getRethink from '../../../database/rethinkDriver' import getKysely from '../../../postgres/getKysely' import {MutationResolvers} from '../resolverTypes' @@ -7,8 +6,6 @@ const flagConversionModal: MutationResolvers['flagConversionModal'] = async ( {active, orgId}, {dataLoader} ) => { - const r = await getRethink() - // VALIDATION const organization = await dataLoader.get('organizations').load(orgId) if (!organization) { @@ -22,13 +19,6 @@ const flagConversionModal: MutationResolvers['flagConversionModal'] = async ( .set({showConversionModal: active}) .where('id', '=', orgId) .execute() - await r - .table('Organization') - .get(orgId) - .update({ - showConversionModal: active - }) - .run() return {orgId} } diff --git a/packages/server/graphql/private/mutations/processRecurrence.ts b/packages/server/graphql/private/mutations/processRecurrence.ts index 57d1e8578d5..22bdd3b5815 100644 --- a/packages/server/graphql/private/mutations/processRecurrence.ts +++ b/packages/server/graphql/private/mutations/processRecurrence.ts @@ -165,7 +165,7 @@ const processRecurrence: MutationResolvers['processRecurrence'] = async (_source } const [seriesOrg, lastMeeting] = await Promise.all([ - dataLoader.get('organizations').load(seriesTeam.orgId), + dataLoader.get('organizations').loadNonNull(seriesTeam.orgId), dataLoader.get('lastMeetingByMeetingSeriesId').load(meetingSeries.id) ]) diff --git a/packages/server/graphql/private/mutations/sendUpcomingInvoiceEmails.ts b/packages/server/graphql/private/mutations/sendUpcomingInvoiceEmails.ts deleted file mode 100644 index d49383b0486..00000000000 --- a/packages/server/graphql/private/mutations/sendUpcomingInvoiceEmails.ts +++ /dev/null @@ -1,153 +0,0 @@ -import {UpcomingInvoiceEmailProps} from 'parabol-client/modules/email/components/UpcomingInvoiceEmail' -import makeAppURL from 'parabol-client/utils/makeAppURL' -import {months} from 'parabol-client/utils/makeDateString' -import {isNotNull} from 'parabol-client/utils/predicates' -import {Threshold} from '../../../../client/types/constEnums' -import appOrigin from '../../../appOrigin' -import getRethink from '../../../database/rethinkDriver' -import {RDatum, RValue} from '../../../database/stricterR' -import UpcomingInvoiceEmailTemplate from '../../../email/UpcomingInvoiceEmailTemplate' -import getMailManager from '../../../email/getMailManager' -import getKysely from '../../../postgres/getKysely' -import IUser from '../../../postgres/types/IUser' -import {MutationResolvers} from '../resolverTypes' - -interface Details extends UpcomingInvoiceEmailProps { - emails: string[] -} - -interface Organization { - id: string - periodEnd: Date - billingLeaderIds: string[] - newUserIds: string[] -} - -const makePeriodEndStr = (periodEnd: Date) => { - const date = new Date(periodEnd) - const month = date.getMonth() - const day = date.getDate() - const monthStr = months[month] - return `${monthStr} ${day}` -} - -const getEmailDetails = (organizations: Organization[], userMap: Map) => { - const details = [] as Details[] - organizations.forEach((organization) => { - const {id: orgId, billingLeaderIds, periodEnd} = organization - const newUsers = organization.newUserIds - .map((id) => { - const newUser = userMap.get(id) - return ( - newUser && { - email: newUser.email, - name: newUser.preferredName - } - ) - }) - .filter((newUser) => newUser !== undefined) as {name: string; email: string}[] - details.push({ - appOrigin, - emails: billingLeaderIds - .map((id) => userMap.get(id)?.email) - .filter((email) => email !== undefined) as string[], - periodEndStr: makePeriodEndStr(periodEnd), - memberUrl: makeAppURL(appOrigin, `me/organizations/${orgId}/members`), - newUsers - }) - }) - return details -} - -const sendUpcomingInvoiceEmails: MutationResolvers['sendUpcomingInvoiceEmails'] = async ( - _source, - _args, - {dataLoader} -) => { - const r = await getRethink() - const now = new Date() - const periodEndThresh = new Date(Date.now() + Threshold.UPCOMING_INVOICE_EMAIL_WARNING) - const lastSentThresh = new Date(Date.now() - Threshold.UPCOMING_INVOICE_EMAIL_WARNING) - - const organizations = (await r - .table('Organization') - .getAll('team', {index: 'tier'}) - .filter((organization: RValue) => - r.and( - organization('periodEnd').le(periodEndThresh).default(false), - organization('upcomingInvoiceEmailSentAt').le(lastSentThresh).default(true) - ) - ) - .coerceTo('array') - .merge((organization: RValue) => ({ - newUserIds: r - .table('OrganizationUser') - .getAll(organization('id'), {index: 'orgId'}) - .filter((organizationUser: RValue) => organizationUser('newUserUntil').ge(now)) - .filter({removedAt: null, role: null})('userId') - .coerceTo('array') - })) - .filter((organization: RValue) => organization('newUserIds').count().ge(1)) - .merge((organization: RValue) => ({ - billingLeaderIds: r - .table('OrganizationUser') - .getAll(organization('id'), {index: 'orgId'}) - .filter({removedAt: null}) - .filter((row: RDatum) => r.expr(['BILLING_LEADER', 'ORG_ADMIN']).contains(row('role')))( - 'userId' - ) - .coerceTo('array') - })) - .coerceTo('array') - .run()) as Organization[] - - if (organizations.length === 0) return [] - - // collect all users to reduce roundtrips to db and do the merging when formatting the data - const allUserIds = organizations.reduce( - (prev, cur) => prev.concat(cur.billingLeaderIds, cur.newUserIds), - [] as string[] - ) - const allUsers = await Promise.all( - allUserIds.map((userId) => dataLoader.get('users').load(userId)) - ) - const allUserMap = allUsers.filter(isNotNull).reduce((prev, cur) => { - prev.set(cur.id, cur) - return prev - }, new Map()) - - const details = getEmailDetails(organizations, allUserMap) - await Promise.all( - details.map((detail) => { - const {emails, ...props} = detail - const {subject, body, html} = UpcomingInvoiceEmailTemplate(props) - return Promise.all( - emails.map((to) => { - return getMailManager().sendEmail({ - to, - subject, - body, - html, - tags: ['type:upcomingInvoice'] - }) - }) - ) - }) - ) - const orgIds = organizations.map(({id}) => id) - await getKysely() - .updateTable('Organization') - .set({upcomingInvoiceEmailSentAt: now}) - .where('id', 'in', orgIds) - .execute() - await r - .table('Organization') - .getAll(r.args(orgIds)) - .update({ - upcomingInvoiceEmailSentAt: now - }) - .run() - return details.map(({emails}) => emails.join(',')) -} - -export default sendUpcomingInvoiceEmails diff --git a/packages/server/graphql/private/mutations/setOrganizationDomain.ts b/packages/server/graphql/private/mutations/setOrganizationDomain.ts index b374ed7ce35..52b649b4bd8 100644 --- a/packages/server/graphql/private/mutations/setOrganizationDomain.ts +++ b/packages/server/graphql/private/mutations/setOrganizationDomain.ts @@ -1,4 +1,3 @@ -import getRethink from '../../../database/rethinkDriver' import getKysely from '../../../postgres/getKysely' import {MutationResolvers} from '../resolverTypes' @@ -7,7 +6,6 @@ const setOrganizationDomain: MutationResolvers['setOrganizationDomain'] = async {orgId, domain}, {dataLoader} ) => { - const r = await getRethink() // VALIDATION const organization = await dataLoader.get('organizations').load(orgId) dataLoader.get('organizations').clear(orgId) @@ -21,14 +19,7 @@ const setOrganizationDomain: MutationResolvers['setOrganizationDomain'] = async .set({activeDomain: domain, isActiveDomainTouched: true}) .where('id', '=', orgId) .execute() - await r - .table('Organization') - .get(orgId) - .update({ - activeDomain: domain, - isActiveDomainTouched: true - }) - .run() + return true } diff --git a/packages/server/graphql/private/mutations/startTrial.ts b/packages/server/graphql/private/mutations/startTrial.ts index cff3800cb7b..2b32eddd1b8 100644 --- a/packages/server/graphql/private/mutations/startTrial.ts +++ b/packages/server/graphql/private/mutations/startTrial.ts @@ -1,5 +1,4 @@ import removeTeamsLimitObjects from '../../../billing/helpers/removeTeamsLimitObjects' -import getRethink from '../../../database/rethinkDriver' import getKysely from '../../../postgres/getKysely' import setTierForOrgUsers from '../../../utils/setTierForOrgUsers' import setUserTierForOrgId from '../../../utils/setUserTierForOrgId' @@ -8,11 +7,12 @@ import hideConversionModal from '../../mutations/helpers/hideConversionModal' import {MutationResolvers} from '../resolverTypes' const startTrial: MutationResolvers['startTrial'] = async (_source, {orgId}, {dataLoader}) => { - const r = await getRethink() const pg = getKysely() const now = new Date() const organization = await dataLoader.get('organizations').load(orgId) - + if (!organization) { + return {error: {message: 'Organization not found'}} + } // VALIDATION if (organization.tier !== 'starter') { return standardError(new Error('Cannot start trial for organization on paid tier')) @@ -30,15 +30,6 @@ const startTrial: MutationResolvers['startTrial'] = async (_source, {orgId}, {da .set({trialStartDate: now, tierLimitExceededAt: null, scheduledLockAt: null, lockedAt: null}) .where('id', '=', orgId) .execute(), - r({ - updatedOrg: r.table('Organization').get(orgId).update({ - trialStartDate: now, - tierLimitExceededAt: null, - scheduledLockAt: null, - lockedAt: null, - updatedAt: now - }) - }).run(), pg.updateTable('Team').set({trialStartDate: now}).where('orgId', '=', orgId).execute(), removeTeamsLimitObjects(orgId, dataLoader) ]) @@ -48,7 +39,7 @@ const startTrial: MutationResolvers['startTrial'] = async (_source, {orgId}, {da await hideConversionModal(orgId, dataLoader) - return {organization} + return {orgId} } export default startTrial diff --git a/packages/server/graphql/private/mutations/stripeCreateSubscription.ts b/packages/server/graphql/private/mutations/stripeCreateSubscription.ts index 4acbd075ce5..2397b8d3ee2 100644 --- a/packages/server/graphql/private/mutations/stripeCreateSubscription.ts +++ b/packages/server/graphql/private/mutations/stripeCreateSubscription.ts @@ -1,5 +1,4 @@ import Stripe from 'stripe' -import getRethink from '../../../database/rethinkDriver' import getKysely from '../../../postgres/getKysely' import {isSuperUser} from '../../../utils/authorization' import {getStripeManager} from '../../../utils/stripe' @@ -10,7 +9,6 @@ const stripeCreateSubscription: MutationResolvers['stripeCreateSubscription'] = {customerId, subscriptionId}, {authToken} ) => { - const r = await getRethink() // AUTH if (!isSuperUser(authToken)) { throw new Error('Don’t be rude.') @@ -47,14 +45,6 @@ const stripeCreateSubscription: MutationResolvers['stripeCreateSubscription'] = .where('id', '=', orgId) .execute() - await r - .table('Organization') - .get(orgId) - .update({ - stripeSubscriptionId: subscriptionId - }) - .run() - return true } diff --git a/packages/server/graphql/private/mutations/stripeDeleteSubscription.ts b/packages/server/graphql/private/mutations/stripeDeleteSubscription.ts index 3b8d2dd1f7f..9a00d98d386 100644 --- a/packages/server/graphql/private/mutations/stripeDeleteSubscription.ts +++ b/packages/server/graphql/private/mutations/stripeDeleteSubscription.ts @@ -1,5 +1,3 @@ -import getRethink from '../../../database/rethinkDriver' -import Organization from '../../../database/types/Organization' import getKysely from '../../../postgres/getKysely' import {isSuperUser} from '../../../utils/authorization' import {getStripeManager} from '../../../utils/stripe' @@ -10,7 +8,6 @@ const stripeDeleteSubscription: MutationResolvers['stripeDeleteSubscription'] = {customerId, subscriptionId}, {authToken, dataLoader} ) => { - const r = await getRethink() // AUTH if (!isSuperUser(authToken)) { throw new Error('Don’t be rude.') @@ -29,7 +26,10 @@ const stripeDeleteSubscription: MutationResolvers['stripeDeleteSubscription'] = if (!orgId) { throw new Error(`orgId not found on metadata for customer ${customerId}`) } - const org: Organization = await dataLoader.get('organizations').load(orgId) + const org = await dataLoader.get('organizations').load(orgId) + if (!org) { + throw new Error(`Organization not found for orgId ${orgId}`) + } const {stripeSubscriptionId} = org if (!stripeSubscriptionId) return false @@ -42,13 +42,6 @@ const stripeDeleteSubscription: MutationResolvers['stripeDeleteSubscription'] = .set({stripeSubscriptionId: null}) .where('id', '=', orgId) .execute() - await r - .table('Organization') - .get(orgId) - .update({ - stripeSubscriptionId: r.literal() - }) - .run() return true } diff --git a/packages/server/graphql/private/mutations/stripeInvoicePaid.ts b/packages/server/graphql/private/mutations/stripeInvoicePaid.ts index 527dd1ec1fa..3d533b39b40 100644 --- a/packages/server/graphql/private/mutations/stripeInvoicePaid.ts +++ b/packages/server/graphql/private/mutations/stripeInvoicePaid.ts @@ -51,16 +51,18 @@ const stripeInvoicePaid: MutationResolvers['stripeInvoicePaid'] = async ( } await Promise.all([ r({ - invoice: r.table('Invoice').get(invoiceId).update({ - creditCard, - paidAt: now, - status: 'PAID' - }), - org: r - .table('Organization') - .get(orgId) + invoice: r + .table('Invoice') + .get(invoiceId) .update({ - stripeSubscriptionId: invoice.subscription as string + creditCard: creditCard + ? { + ...creditCard, + last4: String(creditCard) + } + : undefined, + paidAt: now, + status: 'PAID' }) }).run(), updateTeamByOrgId(teamUpdates, orgId) diff --git a/packages/server/graphql/private/mutations/stripeSucceedPayment.ts b/packages/server/graphql/private/mutations/stripeSucceedPayment.ts index 03a62264dae..76c93a60856 100644 --- a/packages/server/graphql/private/mutations/stripeSucceedPayment.ts +++ b/packages/server/graphql/private/mutations/stripeSucceedPayment.ts @@ -51,16 +51,18 @@ const stripeSucceedPayment: MutationResolvers['stripeSucceedPayment'] = async ( } await Promise.all([ r({ - invoice: r.table('Invoice').get(invoiceId).update({ - creditCard, - paidAt: now, - status: 'PAID' - }), - org: r - .table('Organization') - .get(orgId) + invoice: r + .table('Invoice') + .get(invoiceId) .update({ - stripeSubscriptionId: invoice.subscription as string + creditCard: creditCard + ? { + ...creditCard, + last4: String(creditCard.last4) + } + : undefined, + paidAt: now, + status: 'PAID' }) }).run(), updateTeamByOrgId(teamUpdates, orgId) diff --git a/packages/server/graphql/private/mutations/stripeUpdateCreditCard.ts b/packages/server/graphql/private/mutations/stripeUpdateCreditCard.ts index 2772c922b30..90427111fa3 100644 --- a/packages/server/graphql/private/mutations/stripeUpdateCreditCard.ts +++ b/packages/server/graphql/private/mutations/stripeUpdateCreditCard.ts @@ -1,4 +1,3 @@ -import getRethink from '../../../database/rethinkDriver' import getKysely from '../../../postgres/getKysely' import {toCreditCard} from '../../../postgres/helpers/toCreditCard' import {isSuperUser} from '../../../utils/authorization' @@ -15,7 +14,6 @@ const stripeUpdateCreditCard: MutationResolvers['stripeUpdateCreditCard'] = asyn if (!isSuperUser(authToken)) { throw new Error('Don’t be rude.') } - const r = await getRethink() const manager = getStripeManager() const customer = await manager.retrieveCustomer(customerId) if (customer.deleted) { @@ -33,7 +31,6 @@ const stripeUpdateCreditCard: MutationResolvers['stripeUpdateCreditCard'] = asyn .set({creditCard: toCreditCard(creditCard)}) .where('id', '=', orgId) .execute() - await r.table('Organization').get(orgId).update({creditCard}).run() return true } diff --git a/packages/server/graphql/private/mutations/updateOrgFeatureFlag.ts b/packages/server/graphql/private/mutations/updateOrgFeatureFlag.ts index 733d03ae10b..dbd12ba0c41 100644 --- a/packages/server/graphql/private/mutations/updateOrgFeatureFlag.ts +++ b/packages/server/graphql/private/mutations/updateOrgFeatureFlag.ts @@ -1,6 +1,4 @@ import {sql} from 'kysely' -import {RValue} from 'rethinkdb-ts' -import getRethink from '../../../database/rethinkDriver' import getKysely from '../../../postgres/getKysely' import isValid from '../../isValid' import {MutationResolvers} from '../resolverTypes' @@ -10,7 +8,6 @@ const updateOrgFeatureFlag: MutationResolvers['updateOrgFeatureFlag'] = async ( {orgIds, flag, addFlag}, {dataLoader} ) => { - const r = await getRethink() const existingOrgs = (await dataLoader.get('organizations').loadMany(orgIds)).filter(isValid) const existingIds = existingOrgs.map(({id}) => id) @@ -21,7 +18,7 @@ const updateOrgFeatureFlag: MutationResolvers['updateOrgFeatureFlag'] = async ( } // RESOLUTION - await getKysely() + const updatedOrgIds = await getKysely() .updateTable('Organization') .$if(addFlag, (db) => db.set({featureFlags: sql`arr_append_uniq("featureFlags",${flag})`})) .$if(!addFlag, (db) => @@ -32,24 +29,8 @@ const updateOrgFeatureFlag: MutationResolvers['updateOrgFeatureFlag'] = async ( .where('id', 'in', orgIds) .returning('id') .execute() - const updatedOrgIds = (await r - .table('Organization') - .getAll(r.args(orgIds)) - .update( - (row: RValue) => ({ - featureFlags: r.branch( - addFlag, - row('featureFlags').default([]).setInsert(flag), - row('featureFlags') - .default([]) - .filter((featureFlag: RValue) => featureFlag.ne(flag)) - ) - }), - {returnChanges: true} - )('changes')('new_val')('id') - .run()) as string[] - return {updatedOrgIds} + return {updatedOrgIds: updatedOrgIds.map(({id}) => id)} } export default updateOrgFeatureFlag diff --git a/packages/server/graphql/private/mutations/upgradeToTeamTier.ts b/packages/server/graphql/private/mutations/upgradeToTeamTier.ts index aef8652f492..e0e04d94b5a 100644 --- a/packages/server/graphql/private/mutations/upgradeToTeamTier.ts +++ b/packages/server/graphql/private/mutations/upgradeToTeamTier.ts @@ -45,12 +45,11 @@ const upgradeToTeamTier: MutationResolvers['upgradeToTeamTier'] = async ( const pg = getKysely() const operationId = dataLoader.share() const subOptions = {mutatorId, operationId} - const now = new Date() // AUTH const viewerId = getUserId(authToken) const [organization, viewer] = await Promise.all([ - dataLoader.get('organizations').load(orgId), + dataLoader.get('organizations').loadNonNull(orgId), dataLoader.get('users').loadNonNull(viewerId) ]) @@ -83,19 +82,6 @@ const upgradeToTeamTier: MutationResolvers['upgradeToTeamTier'] = async ( }) .where('id', '=', orgId) .execute(), - r({ - updatedOrg: r.table('Organization').get(orgId).update({ - creditCard, - tier: 'team', - tierLimitExceededAt: null, - scheduledLockAt: null, - lockedAt: null, - updatedAt: now, - trialStartDate: null, - stripeId, - stripeSubscriptionId - }) - }).run(), pg .updateTable('Team') .set({ @@ -125,7 +111,7 @@ const upgradeToTeamTier: MutationResolvers['upgradeToTeamTier'] = async ( const teamIds = teams.map(({id}) => id) analytics.organizationUpgraded(viewer, { orgId, - domain: activeDomain, + domain: activeDomain || undefined, isTrial: !!trialStartDate, orgName, oldTier: 'starter', diff --git a/packages/server/graphql/private/queries/suProOrgInfo.ts b/packages/server/graphql/private/queries/suProOrgInfo.ts index 387ceadc429..870c5237419 100644 --- a/packages/server/graphql/private/queries/suProOrgInfo.ts +++ b/packages/server/graphql/private/queries/suProOrgInfo.ts @@ -1,22 +1,27 @@ import getRethink from '../../../database/rethinkDriver' -import {RValue} from '../../../database/stricterR' +import {RDatum} from '../../../database/stricterR' +import {selectOrganizations} from '../../../dataloader/primaryKeyLoaderMakers' import {QueryResolvers} from '../resolverTypes' const suProOrgInfo: QueryResolvers['suProOrgInfo'] = async (_source, {includeInactive}) => { const r = await getRethink() - return r - .table('Organization') - .getAll('team', {index: 'tier'}) - .merge((organization: RValue) => ({ - users: r - .table('OrganizationUser') - .getAll(organization('id'), {index: 'orgId'}) - .filter({removedAt: null}) - .filter((user: RValue) => r.branch(includeInactive, true, user('inactive').not())) - .count() - })) - .filter((org: RValue) => r.branch(includeInactive, true, org('users').ge(1))) + const proOrgs = await selectOrganizations().where('tier', '=', 'team').execute() + if (includeInactive) return proOrgs + + const proOrgIds = proOrgs.map(({id}) => id) + const activeOrgIds = await ( + r + .table('OrganizationUser') + .getAll(r.args(proOrgIds), {index: 'orgId'}) + .filter({removedAt: null, inactive: false}) + .group('orgId') as RDatum + ) + .count() + .ungroup() + .filter((row: RDatum) => row('reduction').ge(1))('group') .run() + + return proOrgs.filter((org) => activeOrgIds.includes(org.id)) } export default suProOrgInfo diff --git a/packages/server/graphql/private/types/DraftEnterpriseInvoicePayload.ts b/packages/server/graphql/private/types/DraftEnterpriseInvoicePayload.ts index a1aa209e552..1341dbaf9ae 100644 --- a/packages/server/graphql/private/types/DraftEnterpriseInvoicePayload.ts +++ b/packages/server/graphql/private/types/DraftEnterpriseInvoicePayload.ts @@ -8,7 +8,7 @@ export type DraftEnterpriseInvoicePayloadSource = const DraftEnterpriseInvoicePayload: DraftEnterpriseInvoicePayloadResolvers = { organization: (source, _args, {dataLoader}) => { - return 'orgId' in source ? dataLoader.get('organizations').load(source.orgId) : null + return 'orgId' in source ? dataLoader.get('organizations').loadNonNull(source.orgId) : null } } diff --git a/packages/server/graphql/private/types/EndTrialSuccess.ts b/packages/server/graphql/private/types/EndTrialSuccess.ts index 80c645bbfab..4ecfe4bad97 100644 --- a/packages/server/graphql/private/types/EndTrialSuccess.ts +++ b/packages/server/graphql/private/types/EndTrialSuccess.ts @@ -1,11 +1,14 @@ -import Organization from '../../../database/types/Organization' import {EndTrialSuccessResolvers} from '../resolverTypes' export type EndTrialSuccessSource = { - organization: Organization + orgId: string trialStartDate: Date } -const EndTrialSuccess: EndTrialSuccessResolvers = {} +const EndTrialSuccess: EndTrialSuccessResolvers = { + organization: async ({orgId}, _args, {dataLoader}) => { + return dataLoader.get('organizations').loadNonNull(orgId) + } +} export default EndTrialSuccess diff --git a/packages/server/graphql/private/types/FlagConversionModalPayload.ts b/packages/server/graphql/private/types/FlagConversionModalPayload.ts index 7f7e66195fd..9356d163ee1 100644 --- a/packages/server/graphql/private/types/FlagConversionModalPayload.ts +++ b/packages/server/graphql/private/types/FlagConversionModalPayload.ts @@ -8,7 +8,7 @@ export type FlagConversionModalPayloadSource = const FlagConversionModalPayload: FlagConversionModalPayloadResolvers = { org: (source, _args, {dataLoader}) => { - return 'orgId' in source ? dataLoader.get('organizations').load(source.orgId) : null + return 'orgId' in source ? dataLoader.get('organizations').loadNonNull(source.orgId) : null } } diff --git a/packages/server/graphql/private/types/StartTrialSuccess.ts b/packages/server/graphql/private/types/StartTrialSuccess.ts index b166c9164d2..bbe467bb343 100644 --- a/packages/server/graphql/private/types/StartTrialSuccess.ts +++ b/packages/server/graphql/private/types/StartTrialSuccess.ts @@ -1,10 +1,13 @@ -import Organization from '../../../database/types/Organization' import {StartTrialSuccessResolvers} from '../resolverTypes' export type StartTrialSuccessSource = { - organization: Organization + orgId: string } -const StartTrialSuccess: StartTrialSuccessResolvers = {} +const StartTrialSuccess: StartTrialSuccessResolvers = { + organization: async ({orgId}, _args, {dataLoader}) => { + return dataLoader.get('organizations').loadNonNull(orgId) + } +} export default StartTrialSuccess diff --git a/packages/server/graphql/public/mutations/acceptRequestToJoinDomain.ts b/packages/server/graphql/public/mutations/acceptRequestToJoinDomain.ts index 324c4404ecd..9c7cddc6117 100644 --- a/packages/server/graphql/public/mutations/acceptRequestToJoinDomain.ts +++ b/packages/server/graphql/public/mutations/acceptRequestToJoinDomain.ts @@ -57,7 +57,7 @@ const acceptRequestToJoinDomain: MutationResolvers['acceptRequestToJoinDomain'] // Provided request domain should match team's organizations activeDomain const leadTeams = await getTeamsByIds(validTeamMembers.map((teamMember) => teamMember.teamId)) const teamOrgs = await Promise.all( - leadTeams.map((t) => dataLoader.get('organizations').load(t.orgId)) + leadTeams.map((t) => dataLoader.get('organizations').loadNonNull(t.orgId)) ) const validOrgIds = teamOrgs.filter((org) => org.activeDomain === domain).map(({id}) => id) diff --git a/packages/server/graphql/public/mutations/createStripeSubscription.ts b/packages/server/graphql/public/mutations/createStripeSubscription.ts index bebad7a017c..47fb36d4dba 100644 --- a/packages/server/graphql/public/mutations/createStripeSubscription.ts +++ b/packages/server/graphql/public/mutations/createStripeSubscription.ts @@ -15,7 +15,7 @@ const createStripeSubscription: MutationResolvers['createStripeSubscription'] = const [viewer, organization, orgUsersCount, organizationUser] = await Promise.all([ dataLoader.get('users').loadNonNull(viewerId), - dataLoader.get('organizations').load(orgId), + dataLoader.get('organizations').loadNonNull(orgId), r .table('OrganizationUser') .getAll(orgId, {index: 'orgId'}) diff --git a/packages/server/graphql/public/mutations/setMeetingSettings.ts b/packages/server/graphql/public/mutations/setMeetingSettings.ts index 2bbaa9693e4..0b0cc44a899 100644 --- a/packages/server/graphql/public/mutations/setMeetingSettings.ts +++ b/packages/server/graphql/public/mutations/setMeetingSettings.ts @@ -30,7 +30,7 @@ const setMeetingSettings: MutationResolvers['setMeetingSettings'] = async ( dataLoader.get('teams').loadNonNull(teamId), dataLoader.get('users').loadNonNull(viewerId) ]) - const organization = await dataLoader.get('organizations').load(team.orgId) + const organization = await dataLoader.get('organizations').loadNonNull(team.orgId) const {featureFlags} = organization const hasTranscriptFlag = featureFlags?.includes('zoomTranscription') diff --git a/packages/server/graphql/public/mutations/updateCreditCard.ts b/packages/server/graphql/public/mutations/updateCreditCard.ts index 984c9758e17..d5bcade0c82 100644 --- a/packages/server/graphql/public/mutations/updateCreditCard.ts +++ b/packages/server/graphql/public/mutations/updateCreditCard.ts @@ -1,7 +1,6 @@ import {SubscriptionChannel} from 'parabol-client/types/constEnums' import Stripe from 'stripe' import removeTeamsLimitObjects from '../../../billing/helpers/removeTeamsLimitObjects' -import getRethink from '../../../database/rethinkDriver' import getKysely from '../../../postgres/getKysely' import {toCreditCard} from '../../../postgres/helpers/toCreditCard' import updateTeamByOrgId from '../../../postgres/queries/updateTeamByOrgId' @@ -21,8 +20,6 @@ const updateCreditCard: MutationResolvers['updateCreditCard'] = async ( ) => { const operationId = dataLoader.share() const subOptions = {mutatorId, operationId} - const now = new Date() - const r = await getRethink() // AUTH const viewerId = getUserId(authToken) @@ -31,7 +28,9 @@ const updateCreditCard: MutationResolvers['updateCreditCard'] = async ( } // RESOLUTION - const organization = await dataLoader.get('organizations').load(orgId) + const organization = await dataLoader.get('organizations').loadNonNull(orgId) + if (!organization) return {error: {message: 'Organization not found'}} + const {stripeId, stripeSubscriptionId} = organization if (!stripeId || !stripeSubscriptionId) { return standardError(new Error('Organization is not subscribed to a plan'), {userId: viewerId}) @@ -70,17 +69,6 @@ const updateCreditCard: MutationResolvers['updateCreditCard'] = async ( }) .where('id', '=', orgId) .execute(), - r({ - updatedOrg: r.table('Organization').get(orgId).update({ - creditCard, - tier: 'team', - stripeId: customer.id, - tierLimitExceededAt: null, - scheduledLockAt: null, - lockedAt: null, - updatedAt: now - }) - }).run(), updateTeamByOrgId( { isPaid: true, @@ -89,7 +77,7 @@ const updateCreditCard: MutationResolvers['updateCreditCard'] = async ( orgId ) ]) - organization.creditCard = creditCard + dataLoader.get('organizations').clear(orgId) // If there are unpaid open invoices, try to process them const openInvoices = (await manager.listSubscriptionOpenInvoices(stripeSubscriptionId)).data diff --git a/packages/server/graphql/public/mutations/updateOrg.ts b/packages/server/graphql/public/mutations/updateOrg.ts index 3aedaf7d82c..fb641c5c4bf 100644 --- a/packages/server/graphql/public/mutations/updateOrg.ts +++ b/packages/server/graphql/public/mutations/updateOrg.ts @@ -1,5 +1,4 @@ import {SubscriptionChannel} from 'parabol-client/types/constEnums' -import getRethink from '../../../database/rethinkDriver' import getKysely from '../../../postgres/getKysely' import {getUserId, isUserBillingLeader} from '../../../utils/authorization' import publish from '../../../utils/publish' @@ -11,8 +10,6 @@ const updateOrg: MutationResolvers['updateOrg'] = async ( {updatedOrg}, {authToken, dataLoader, socketId: mutatorId} ) => { - const r = await getRethink() - const now = new Date() const operationId = dataLoader.share() const subOptions = {mutatorId, operationId} @@ -37,17 +34,11 @@ const updateOrg: MutationResolvers['updateOrg'] = async ( } // RESOLUTION - const dbUpdate = { - id: orgId, - name: normalizedName, - updatedAt: now - } await getKysely() .updateTable('Organization') .set({name: normalizedName}) .where('id', '=', orgId) .execute() - await r.table('Organization').get(orgId).update(dbUpdate).run() const data = {orgId} publish(SubscriptionChannel.ORGANIZATION, orgId, 'UpdateOrgPayload', data, subOptions) diff --git a/packages/server/graphql/public/mutations/uploadOrgImage.ts b/packages/server/graphql/public/mutations/uploadOrgImage.ts index e03a52f2ce7..f728a4805da 100644 --- a/packages/server/graphql/public/mutations/uploadOrgImage.ts +++ b/packages/server/graphql/public/mutations/uploadOrgImage.ts @@ -1,5 +1,4 @@ import {SubscriptionChannel} from 'parabol-client/types/constEnums' -import getRethink from '../../../database/rethinkDriver' import getFileStoreManager from '../../../fileStorage/getFileStoreManager' import normalizeAvatarUpload from '../../../fileStorage/normalizeAvatarUpload' import validateAvatarUpload from '../../../fileStorage/validateAvatarUpload' @@ -14,8 +13,6 @@ const uploadOrgImage: MutationResolvers['uploadOrgImage'] = async ( {file, orgId}, {authToken, dataLoader, socketId: mutatorId} ) => { - const r = await getRethink() - const now = new Date() const operationId = dataLoader.share() const subOptions = {mutatorId, operationId} @@ -39,15 +36,6 @@ const uploadOrgImage: MutationResolvers['uploadOrgImage'] = async ( .set({picture: publicLocation}) .where('id', '=', orgId) .execute() - await r - .table('Organization') - .get(orgId) - .update({ - id: orgId, - picture: publicLocation, - updatedAt: now - }) - .run() const data = {orgId} publish(SubscriptionChannel.ORGANIZATION, orgId, 'UpdateOrgPayload', data, subOptions) diff --git a/packages/server/graphql/public/types/AddApprovedOrganizationDomainsSuccess.ts b/packages/server/graphql/public/types/AddApprovedOrganizationDomainsSuccess.ts index 8ab3e35500a..b3adbe5fc56 100644 --- a/packages/server/graphql/public/types/AddApprovedOrganizationDomainsSuccess.ts +++ b/packages/server/graphql/public/types/AddApprovedOrganizationDomainsSuccess.ts @@ -6,7 +6,7 @@ export type AddApprovedOrganizationDomainsSuccessSource = { const AddApprovedOrganizationDomainsSuccess: AddApprovedOrganizationDomainsSuccessResolvers = { organization: async ({orgId}, _args, {dataLoader}) => { - return dataLoader.get('organizations').load(orgId) + return dataLoader.get('organizations').loadNonNull(orgId) } } diff --git a/packages/server/graphql/public/types/AddPokerTemplateSuccess.ts b/packages/server/graphql/public/types/AddPokerTemplateSuccess.ts index f6d2b3d9146..b881e454d1a 100644 --- a/packages/server/graphql/public/types/AddPokerTemplateSuccess.ts +++ b/packages/server/graphql/public/types/AddPokerTemplateSuccess.ts @@ -1,5 +1,5 @@ import {getUserId} from '../../../utils/authorization' -import {AddPokerTemplateSuccessResolvers, PokerTemplate} from '../resolverTypes' +import {AddPokerTemplateSuccessResolvers} from '../resolverTypes' export type AddPokerTemplateSuccessSource = { templateId: string @@ -7,7 +7,7 @@ export type AddPokerTemplateSuccessSource = { const AddPokerTemplateSuccess: AddPokerTemplateSuccessResolvers = { pokerTemplate: async ({templateId}, _args, {dataLoader}) => { - return (await dataLoader.get('meetingTemplates').load(templateId)) as PokerTemplate + return await dataLoader.get('meetingTemplates').loadNonNull(templateId) }, user: async (_src, _args, {authToken, dataLoader}) => { const viewerId = getUserId(authToken) diff --git a/packages/server/graphql/public/types/DomainJoinRequest.ts b/packages/server/graphql/public/types/DomainJoinRequest.ts index 0b030ce8d79..772526f3cbd 100644 --- a/packages/server/graphql/public/types/DomainJoinRequest.ts +++ b/packages/server/graphql/public/types/DomainJoinRequest.ts @@ -32,7 +32,7 @@ const DomainJoinRequest: DomainJoinRequestResolvers = { const leadTeamIds = leadTeamMembers.map((teamMember) => teamMember.teamId) const leadTeams = (await dataLoader.get('teams').loadMany(leadTeamIds)).filter(isValid) const teamOrgs = await Promise.all( - leadTeams.map((t) => dataLoader.get('organizations').load(t.orgId)) + leadTeams.map((t) => dataLoader.get('organizations').loadNonNull(t.orgId)) ) const validOrgIds = teamOrgs.filter((org) => org.activeDomain === domain).map(({id}) => id) diff --git a/packages/server/graphql/public/types/NewMeeting.ts b/packages/server/graphql/public/types/NewMeeting.ts index 24452df666e..d4e8f36c565 100644 --- a/packages/server/graphql/public/types/NewMeeting.ts +++ b/packages/server/graphql/public/types/NewMeeting.ts @@ -48,7 +48,7 @@ const NewMeeting: NewMeetingResolvers = { organization: async ({teamId}, _args, {dataLoader}) => { const team = await dataLoader.get('teams').loadNonNull(teamId) const {orgId} = team - return dataLoader.get('organizations').load(orgId) + return dataLoader.get('organizations').loadNonNull(orgId) }, phases: async ({phases, id: meetingId, teamId, endedAt}, _args, {authToken, dataLoader}) => { const viewerId = getUserId(authToken) diff --git a/packages/server/graphql/public/types/NotifyPaymentRejected.ts b/packages/server/graphql/public/types/NotifyPaymentRejected.ts index 179167fa37c..19cdd7f5b43 100644 --- a/packages/server/graphql/public/types/NotifyPaymentRejected.ts +++ b/packages/server/graphql/public/types/NotifyPaymentRejected.ts @@ -3,7 +3,7 @@ import {NotifyPaymentRejectedResolvers} from '../resolverTypes' const NotifyPaymentRejected: NotifyPaymentRejectedResolvers = { __isTypeOf: ({type}) => type === 'PAYMENT_REJECTED', organization: async ({orgId}, _args, {dataLoader}) => { - return dataLoader.get('organizations').load(orgId) + return dataLoader.get('organizations').loadNonNull(orgId) } } diff --git a/packages/server/graphql/public/types/NotifyPromoteToOrgLeader.ts b/packages/server/graphql/public/types/NotifyPromoteToOrgLeader.ts index bd481628efe..3fe01a16c1d 100644 --- a/packages/server/graphql/public/types/NotifyPromoteToOrgLeader.ts +++ b/packages/server/graphql/public/types/NotifyPromoteToOrgLeader.ts @@ -3,7 +3,7 @@ import {NotifyPromoteToOrgLeaderResolvers} from '../resolverTypes' const NotifyPromoteToOrgLeader: NotifyPromoteToOrgLeaderResolvers = { __isTypeOf: ({type}) => type === 'PROMOTE_TO_BILLING_LEADER', organization: async ({orgId}, _args, {dataLoader}) => { - return dataLoader.get('organizations').load(orgId) + return dataLoader.get('organizations').loadNonNull(orgId) } } diff --git a/packages/server/graphql/public/types/Organization.ts b/packages/server/graphql/public/types/Organization.ts index bb50fa30a33..15b84bd3907 100644 --- a/packages/server/graphql/public/types/Organization.ts +++ b/packages/server/graphql/public/types/Organization.ts @@ -1,7 +1,12 @@ +import {ExtractTypeFromQueryBuilderSelect} from '../../../../client/types/generics' +import {selectOrganizations} from '../../../dataloader/primaryKeyLoaderMakers' import {isSuperUser} from '../../../utils/authorization' import {getFeatureTier} from '../../types/helpers/getFeatureTier' import {OrganizationResolvers} from '../resolverTypes' +export interface OrganizationSource + extends ExtractTypeFromQueryBuilderSelect {} + const Organization: OrganizationResolvers = { approvedDomains: async ({id: orgId}, _args, {dataLoader}) => { return dataLoader.get('organizationApprovedDomainsByOrgId').load(orgId) diff --git a/packages/server/graphql/public/types/RemoveApprovedOrganizationDomainsSuccess.ts b/packages/server/graphql/public/types/RemoveApprovedOrganizationDomainsSuccess.ts index e1efaba3de2..16f94d37863 100644 --- a/packages/server/graphql/public/types/RemoveApprovedOrganizationDomainsSuccess.ts +++ b/packages/server/graphql/public/types/RemoveApprovedOrganizationDomainsSuccess.ts @@ -7,7 +7,7 @@ export type RemoveApprovedOrganizationDomainsSuccessSource = { const RemoveApprovedOrganizationDomainsSuccess: RemoveApprovedOrganizationDomainsSuccessResolvers = { organization: async ({orgId}, _args, {dataLoader}) => { - return dataLoader.get('organizations').load(orgId) + return dataLoader.get('organizations').loadNonNull(orgId) } } diff --git a/packages/server/graphql/public/types/SAML.ts b/packages/server/graphql/public/types/SAML.ts index 93db425c722..24312a433d4 100644 --- a/packages/server/graphql/public/types/SAML.ts +++ b/packages/server/graphql/public/types/SAML.ts @@ -17,7 +17,7 @@ const SAML: SamlResolvers = { }, organization: async ({orgId}, _args, {dataLoader}) => { if (!orgId) return null - return dataLoader.get('organizations').load(orgId) + return dataLoader.get('organizations').loadNonNull(orgId) } } diff --git a/packages/server/graphql/public/types/SetOrgUserRoleSuccess.ts b/packages/server/graphql/public/types/SetOrgUserRoleSuccess.ts index 53169f52620..9d70f951f12 100644 --- a/packages/server/graphql/public/types/SetOrgUserRoleSuccess.ts +++ b/packages/server/graphql/public/types/SetOrgUserRoleSuccess.ts @@ -10,7 +10,7 @@ export type SetOrgUserRoleSuccessSource = { const SetOrgUserRoleSuccess: SetOrgUserRoleSuccessResolvers = { organization: async ({orgId}, _args, {dataLoader}) => { - return dataLoader.get('organizations').load(orgId) + return dataLoader.get('organizations').loadNonNull(orgId) }, updatedOrgMember: async ({organizationUserId}, _args, {dataLoader}) => { return dataLoader.get('organizationUsers').load(organizationUserId) diff --git a/packages/server/graphql/public/types/StripeFailPaymentPayload.ts b/packages/server/graphql/public/types/StripeFailPaymentPayload.ts index 55543c86818..e04fa093e86 100644 --- a/packages/server/graphql/public/types/StripeFailPaymentPayload.ts +++ b/packages/server/graphql/public/types/StripeFailPaymentPayload.ts @@ -8,7 +8,7 @@ export type StripeFailPaymentPayloadSource = { const StripeFailPaymentPayload: StripeFailPaymentPayloadResolvers = { organization: ({orgId}, _args, {dataLoader}) => { - return dataLoader.get('organizations').load(orgId) + return dataLoader.get('organizations').loadNonNull(orgId) }, notification: async ({notificationId}, _args, {dataLoader}) => { const notification = await dataLoader.get('notifications').load(notificationId) diff --git a/packages/server/graphql/public/types/Team.ts b/packages/server/graphql/public/types/Team.ts index cfb3d4122f4..4c13a4adc0f 100644 --- a/packages/server/graphql/public/types/Team.ts +++ b/packages/server/graphql/public/types/Team.ts @@ -1,9 +1,13 @@ import TeamInsightsId from 'parabol-client/shared/gqlIds/TeamInsightsId' +import {ExtractTypeFromQueryBuilderSelect} from '../../../../client/types/generics' import toTeamMemberId from '../../../../client/utils/relay/toTeamMemberId' +import {selectTeams} from '../../../dataloader/primaryKeyLoaderMakers' import {getUserId, isTeamMember} from '../../../utils/authorization' import {getFeatureTier} from '../../types/helpers/getFeatureTier' import {TeamResolvers} from '../resolverTypes' +export interface TeamSource extends ExtractTypeFromQueryBuilderSelect {} + const Team: TeamResolvers = { insights: async ( {id, orgId, mostUsedEmojis, meetingEngagement, topRetroTemplates}, diff --git a/packages/server/graphql/public/types/UpdateCreditCardSuccess.ts b/packages/server/graphql/public/types/UpdateCreditCardSuccess.ts index b9a40ba01d8..463ffba04dd 100644 --- a/packages/server/graphql/public/types/UpdateCreditCardSuccess.ts +++ b/packages/server/graphql/public/types/UpdateCreditCardSuccess.ts @@ -8,7 +8,7 @@ export type UpdateCreditCardSuccessSource = { const UpdateCreditCardSuccess: UpdateCreditCardSuccessResolvers = { organization: async ({orgId}, _args, {dataLoader}) => { - return dataLoader.get('organizations').load(orgId) + return dataLoader.get('organizations').loadNonNull(orgId) }, teamsUpdated: async ({teamIds}, _args, {dataLoader}) => { const teams = await dataLoader.get('teams').loadMany(teamIds) diff --git a/packages/server/graphql/public/types/UpdateOrgPayload.ts b/packages/server/graphql/public/types/UpdateOrgPayload.ts index e5554f2a74c..6343a50f0ce 100644 --- a/packages/server/graphql/public/types/UpdateOrgPayload.ts +++ b/packages/server/graphql/public/types/UpdateOrgPayload.ts @@ -10,7 +10,7 @@ const UpdateOrgPayload: UpdateOrgPayloadResolvers = { organization: async (source, _args, {dataLoader}) => { if ('error' in source) return null const {orgId} = source - return dataLoader.get('organizations').load(orgId) + return dataLoader.get('organizations').loadNonNull(orgId) } } diff --git a/packages/server/graphql/public/types/UpgradeToTeamTierSuccess.ts b/packages/server/graphql/public/types/UpgradeToTeamTierSuccess.ts index fc985cb0939..038fd98f4ef 100644 --- a/packages/server/graphql/public/types/UpgradeToTeamTierSuccess.ts +++ b/packages/server/graphql/public/types/UpgradeToTeamTierSuccess.ts @@ -9,7 +9,7 @@ export type UpgradeToTeamTierSuccessSource = { const UpgradeToTeamTierSuccess: UpgradeToTeamTierSuccessResolvers = { organization: async ({orgId}, _args, {dataLoader}) => { - return dataLoader.get('organizations').load(orgId) + return dataLoader.get('organizations').loadNonNull(orgId) }, teams: async ({teamIds}, _args, {dataLoader}) => { const teams = await dataLoader.get('teams').loadMany(teamIds) diff --git a/packages/server/graphql/public/types/User.ts b/packages/server/graphql/public/types/User.ts index 7947deed713..df2a04b8374 100644 --- a/packages/server/graphql/public/types/User.ts +++ b/packages/server/graphql/public/types/User.ts @@ -72,7 +72,7 @@ const User: UserResolvers = { .map(({orgId}) => orgId) const organizations = await Promise.all( - orgIds.map((orgId) => dataLoader.get('organizations').load(orgId)) + orgIds.map((orgId) => dataLoader.get('organizations').loadNonNull(orgId)) ) const approvedDomains = organizations.map(({activeDomain}) => activeDomain).filter(isNotNull) return [...new Set(approvedDomains)].map((id) => ({id})) diff --git a/packages/server/graphql/queries/helpers/countTiersForUserId.ts b/packages/server/graphql/queries/helpers/countTiersForUserId.ts index cd031ccf2e5..4e5aa7eee19 100644 --- a/packages/server/graphql/queries/helpers/countTiersForUserId.ts +++ b/packages/server/graphql/queries/helpers/countTiersForUserId.ts @@ -1,5 +1,4 @@ import getRethink from '../../../database/rethinkDriver' -import {RValue} from '../../../database/stricterR' import OrganizationUser from '../../../database/types/OrganizationUser' // breaking this out into its own helper so it can be used directly to @@ -11,9 +10,6 @@ const countTiersForUserId = async (userId: string) => { .table('OrganizationUser') .getAll(userId, {index: 'userId'}) .filter({inactive: false, removedAt: null}) - .merge((organizationUser: RValue) => ({ - tier: r.table('Organization').get(organizationUser('orgId'))('tier').default('starter') - })) .run()) as OrganizationUser[] const tierStarterCount = organizationUsers.filter( (organizationUser) => organizationUser.tier === 'starter' diff --git a/packages/server/graphql/queries/helpers/makeUpcomingInvoice.ts b/packages/server/graphql/queries/helpers/makeUpcomingInvoice.ts index 13b53503b98..0ad3a1755f7 100644 --- a/packages/server/graphql/queries/helpers/makeUpcomingInvoice.ts +++ b/packages/server/graphql/queries/helpers/makeUpcomingInvoice.ts @@ -1,14 +1,14 @@ import dayjs from 'dayjs' import Stripe from 'stripe' import Invoice from '../../../database/types/Invoice' -import Organization from '../../../database/types/Organization' import {fromEpochSeconds} from '../../../utils/epochTime' import getUpcomingInvoiceId from '../../../utils/getUpcomingInvoiceId' import {getStripeManager} from '../../../utils/stripe' import StripeManager from '../../../utils/stripe/StripeManager' +import {OrganizationSource} from '../../public/types/Organization' export default async function makeUpcomingInvoice( - org: Organization, + org: OrganizationSource, quantity: number, stripeId?: string | null ): Promise { diff --git a/packages/server/graphql/queries/invoices.ts b/packages/server/graphql/queries/invoices.ts index c9e043efd8c..82cab7a8849 100644 --- a/packages/server/graphql/queries/invoices.ts +++ b/packages/server/graphql/queries/invoices.ts @@ -38,7 +38,7 @@ export default { } // RESOLUTION - const {stripeId} = await dataLoader.get('organizations').load(orgId) + const {stripeId} = await dataLoader.get('organizations').loadNonNull(orgId) const dbAfter = after ? new Date(after) : r.maxval const [tooManyInvoices, orgUserCount] = await Promise.all([ r @@ -64,7 +64,7 @@ export default { .count() .run() ]) - const org = await dataLoader.get('organizations').load(orgId) + const org = await dataLoader.get('organizations').loadNonNull(orgId) const upcomingInvoice = after ? undefined : await makeUpcomingInvoice(org, orgUserCount, stripeId) diff --git a/packages/server/graphql/types/Organization.ts b/packages/server/graphql/types/Organization.ts index f729b625b85..452c765de79 100644 --- a/packages/server/graphql/types/Organization.ts +++ b/packages/server/graphql/types/Organization.ts @@ -83,7 +83,7 @@ const Organization: GraphQLObjectType = new GraphQLObjectType = new GraphQLObjectType { const [allTeamsOnOrg, organization] = await Promise.all([ dataLoader.get('teamsByOrgIds').load(orgId), - dataLoader.get('organizations').load(orgId) + dataLoader.get('organizations').loadNonNull(orgId) ]) const hasPublicTeamsFlag = !!organization.featureFlags?.includes('publicTeams') if (!isSuperUser(authToken) || !hasPublicTeamsFlag) return [] diff --git a/packages/server/graphql/types/Team.ts b/packages/server/graphql/types/Team.ts index 7d737ad98b1..f1c2ec71be7 100644 --- a/packages/server/graphql/types/Team.ts +++ b/packages/server/graphql/types/Team.ts @@ -259,7 +259,7 @@ const Team: GraphQLObjectType = new GraphQLObjectType({ _args: unknown, {authToken, dataLoader}: GQLContext ) => { - const organization = await dataLoader.get('organizations').load(orgId) + const organization = await dataLoader.get('organizations').loadNonNull(orgId) // TODO this is bad, we should probably just put the perms on each field in the org if (!isTeamMember(authToken, teamId)) { return { diff --git a/packages/server/graphql/types/User.ts b/packages/server/graphql/types/User.ts index 78b6b2380dd..0054e59fa36 100644 --- a/packages/server/graphql/types/User.ts +++ b/packages/server/graphql/types/User.ts @@ -16,7 +16,6 @@ import { } from '../../../client/utils/constants' import groupReflections from '../../../client/utils/smartGroup/groupReflections' import MeetingMemberType from '../../database/types/MeetingMember' -import OrganizationType from '../../database/types/Organization' import OrganizationUserType from '../../database/types/OrganizationUser' import SuggestedActionType from '../../database/types/SuggestedAction' import getKysely from '../../postgres/getKysely' @@ -24,7 +23,6 @@ import {getUserId, isSuperUser, isTeamMember} from '../../utils/authorization' import getMonthlyStreak from '../../utils/getMonthlyStreak' import getRedis from '../../utils/getRedis' import standardError from '../../utils/standardError' -import errorFilter from '../errorFilter' import {DataLoaderWorker, GQLContext} from '../graphql' import isValid from '../isValid' import invoices from '../queries/invoices' @@ -405,11 +403,11 @@ const User: GraphQLObjectType = new GraphQLObjectType orgId) + const orgIds = organizationUsers.map(({orgId}) => orgId) const organizations = (await dataLoader.get('organizations').loadMany(orgIds)).filter( - errorFilter + isValid ) - organizations.sort((a: OrganizationType, b: OrganizationType) => (a.name > b.name ? 1 : -1)) + organizations.sort((a, b) => (a.name > b.name ? 1 : -1)) const viewerId = getUserId(authToken) if (viewerId === userId || isSuperUser(authToken)) { return organizations @@ -417,10 +415,8 @@ const User: GraphQLObjectType = new GraphQLObjectType orgId) - return organizations.filter((organization: OrganizationType) => - viewerOrgIds.includes(organization.id) - ) + const viewerOrgIds = viewerOrganizationUsers.map(({orgId}) => orgId) + return organizations.filter((organization) => viewerOrgIds.includes(organization.id)) } }, overLimitCopy: { diff --git a/packages/server/graphql/types/helpers/isMeetingLocked.ts b/packages/server/graphql/types/helpers/isMeetingLocked.ts index 9ef490b93ec..e455a26b02e 100644 --- a/packages/server/graphql/types/helpers/isMeetingLocked.ts +++ b/packages/server/graphql/types/helpers/isMeetingLocked.ts @@ -30,7 +30,7 @@ const isMeetingLocked = async ( // Archived teams are not updated with the current tier, just check the organization if (isArchived) { - const organization = await dataLoader.get('organizations').load(orgId) + const organization = await dataLoader.get('organizations').loadNonNull(orgId) if (getFeatureTier(organization) !== 'starter') { return false } diff --git a/packages/server/postgres/queries/src/updateUserTiersQuery.sql b/packages/server/postgres/queries/src/updateUserTiersQuery.sql deleted file mode 100644 index 4f8bef44487..00000000000 --- a/packages/server/postgres/queries/src/updateUserTiersQuery.sql +++ /dev/null @@ -1,9 +0,0 @@ -/* - @name updateUserTiersQuery - @param users -> ((tier, trialStartDate, id)...) -*/ -UPDATE "User" AS u SET - "tier" = c."tier"::"TierEnum", - "trialStartDate" = c."trialStartDate"::TIMESTAMP -FROM (VALUES :users) AS c("tier", "trialStartDate", "id") -WHERE c."id" = u."id"; diff --git a/packages/server/postgres/queries/updateUserTiers.ts b/packages/server/postgres/queries/updateUserTiers.ts deleted file mode 100644 index ec9961dad57..00000000000 --- a/packages/server/postgres/queries/updateUserTiers.ts +++ /dev/null @@ -1,11 +0,0 @@ -import getPg from '../getPg' -import catchAndLog from '../utils/catchAndLog' -import {IUpdateUserTiersQueryParams, updateUserTiersQuery} from './generated/updateUserTiersQuery' - -const updateUserTiers = async ({users}: IUpdateUserTiersQueryParams) => { - if (users.length) { - await catchAndLog(() => updateUserTiersQuery.run({users}, getPg())) - } -} - -export default updateUserTiers diff --git a/packages/server/safeMutations/acceptTeamInvitation.ts b/packages/server/safeMutations/acceptTeamInvitation.ts index be5ec868c6d..13c055771c9 100644 --- a/packages/server/safeMutations/acceptTeamInvitation.ts +++ b/packages/server/safeMutations/acceptTeamInvitation.ts @@ -4,14 +4,14 @@ import getRethink from '../database/rethinkDriver' import SuggestedActionCreateNewTeam from '../database/types/SuggestedActionCreateNewTeam' import generateUID from '../generateUID' import {DataLoaderWorker} from '../graphql/graphql' -import {Team} from '../postgres/queries/getTeamsByIds' +import {TeamSource} from '../graphql/public/types/Team' import getNewTeamLeadUserId from '../safeQueries/getNewTeamLeadUserId' import {Logger} from '../utils/Logger' import setUserTierForUserIds from '../utils/setUserTierForUserIds' import addTeamIdToTMS from './addTeamIdToTMS' import insertNewTeamMember from './insertNewTeamMember' -const handleFirstAcceptedInvitation = async (team: Team): Promise => { +const handleFirstAcceptedInvitation = async (team: TeamSource): Promise => { const r = await getRethink() const now = new Date() const {id: teamId, isOnboardTeam} = team @@ -46,7 +46,11 @@ const handleFirstAcceptedInvitation = async (team: Team): Promise return newTeamLeadUserId } -const acceptTeamInvitation = async (team: Team, userId: string, dataLoader: DataLoaderWorker) => { +const acceptTeamInvitation = async ( + team: TeamSource, + userId: string, + dataLoader: DataLoaderWorker +) => { const r = await getRethink() const now = new Date() const {id: teamId, orgId} = team diff --git a/packages/server/safeMutations/safeArchiveEmptyStarterOrganization.ts b/packages/server/safeMutations/safeArchiveEmptyStarterOrganization.ts index e53f082ffce..86205cdf3f8 100644 --- a/packages/server/safeMutations/safeArchiveEmptyStarterOrganization.ts +++ b/packages/server/safeMutations/safeArchiveEmptyStarterOrganization.ts @@ -15,7 +15,7 @@ const safeArchiveEmptyStarterOrganization = async ( const teamCountRemainingOnOldOrg = orgTeams.length if (teamCountRemainingOnOldOrg > 0) return - const org = await dataLoader.get('organizations').load(orgId) + const org = await dataLoader.get('organizations').loadNonNull(orgId) if (org.tier !== 'starter') return await r diff --git a/packages/server/utils/__tests__/isRequestToJoinDomainAllowed.test.ts b/packages/server/utils/__tests__/isRequestToJoinDomainAllowed.test.ts index 1a152cfa456..2739ab07490 100644 --- a/packages/server/utils/__tests__/isRequestToJoinDomainAllowed.test.ts +++ b/packages/server/utils/__tests__/isRequestToJoinDomainAllowed.test.ts @@ -1,9 +1,11 @@ /* eslint-env jest */ +import {sql} from 'kysely' import {r} from 'rethinkdb-ts' import getRethinkConfig from '../../database/getRethinkConfig' import getRethink from '../../database/rethinkDriver' import {TierEnum} from '../../database/types/Invoice' import OrganizationUser from '../../database/types/OrganizationUser' +import {createPGTables} from '../../dataloader/__tests__/isOrgVerified.test' import generateUID from '../../generateUID' import {DataLoaderWorker} from '../../graphql/graphql' import getKysely from '../../postgres/getKysely' @@ -67,7 +69,6 @@ const addOrg = async ( removedAt: member.removedAt ?? null })) await getKysely().insertInto('Organization').values(org).execute() - await r.table('Organization').insert(org).run() await r.table('OrganizationUser').insert(orgUsers).run() return orgId } @@ -96,17 +97,22 @@ const dataLoader = { beforeAll(async () => { await r.connectPool(testConfig) + const pg = getKysely() try { await r.dbDrop(TEST_DB).run() } catch (e) { //ignore } + await pg.schema.createSchema(TEST_DB).ifNotExists().execute() + sql`SET search_path TO '${TEST_DB}'`.execute(pg) await r.dbCreate(TEST_DB).run() - await createTables('Organization', 'OrganizationUser') + await createPGTables('Organization') + await createTables('OrganizationUser') }) afterEach(async () => { - await r.table('Organization').delete().run() + const pg = getKysely() + await sql`truncate table ${sql.table('Organization')}`.execute(pg) await r.table('OrganizationUser').delete().run() }) diff --git a/packages/server/utils/isRequestToJoinDomainAllowed.ts b/packages/server/utils/isRequestToJoinDomainAllowed.ts index 7fef1c6cbe1..adf5d0ff4b7 100644 --- a/packages/server/utils/isRequestToJoinDomainAllowed.ts +++ b/packages/server/utils/isRequestToJoinDomainAllowed.ts @@ -1,10 +1,5 @@ -import getRethink from '../database/rethinkDriver' -import {RDatum} from '../database/stricterR' -import Organization from '../database/types/Organization' -import TeamMember from '../database/types/TeamMember' import User from '../database/types/User' import {DataLoaderWorker} from '../graphql/graphql' -import isValid from '../graphql/isValid' import isUserVerified from './isUserVerified' export const getEligibleOrgIdsByDomain = async ( @@ -17,73 +12,46 @@ export const getEligibleOrgIdsByDomain = async ( return [] } - const r = await getRethink() + const orgs = await dataLoader.get('organizationsByActiveDomain').load(activeDomain) + if (orgs.length === 0) return [] - const orgs = await r - .table('Organization') - .getAll(activeDomain, {index: 'activeDomain'}) - .filter((org: RDatum) => org('featureFlags').default([]).contains('noPromptToJoinOrg').not()) - .merge((org: RDatum) => ({ - members: r - .table('OrganizationUser') - .getAll(org('id'), {index: 'orgId'}) - .orderBy('joinedAt') - .coerceTo('array') - })) - .merge((org: RDatum) => ({ - founder: org('members').nth(0).default(null), - billingLeads: org('members') - .filter({inactive: false, removedAt: null}) - .filter((row: RDatum) => r.expr(['BILLING_LEADER', 'ORG_ADMIN']).contains(row('role'))), - activeMembers: org('members').filter({inactive: false, removedAt: null}).count() - })) - .filter((org: RDatum) => - org('activeMembers').gt(1).and(org('members').filter({userId}).isEmpty()) - ) - .run() + const viewerOrgUsers = await dataLoader.get('organizationUsersByUserId').load(userId) + const viewerOrgIds = viewerOrgUsers.map(({orgId}) => orgId) + const newOrgs = orgs.filter((org) => !viewerOrgIds.includes(org.id)) + if (newOrgs.length === 0) return [] - type OrgWithActiveMembers = Organization & {activeMembers: number} - const eligibleOrgs = (await Promise.all( - orgs.map(async (org) => { - const {founder} = org - const importantMembers = org.billingLeads.slice() as TeamMember[] - if ( - !founder.inactive && - !founder.removedAt && - founder.role !== 'BILLING_LEADER' && - founder.role !== 'ORG_ADMIN' - ) { - importantMembers.push(founder) - } + const verifiedOrgMask = await Promise.all( + newOrgs.map(({id}) => dataLoader.get('isOrgVerified').load(id)) + ) + const verifiedOrgs = newOrgs.filter((_, idx) => verifiedOrgMask[idx]) + const verifiedOrgUsers = await Promise.all( + verifiedOrgs.map((org) => dataLoader.get('organizationUsersByOrgId').load(org.id)) + ) - const users = ( - await dataLoader.get('users').loadMany(importantMembers.map(({userId}) => userId)) - ).filter(isValid) - if ( - !users.some((user) => user.email.split('@')[1] === activeDomain && isUserVerified(user)) - ) { - return null - } - return org - }) - )) as OrgWithActiveMembers[] + const verifiedOrgsWithActiveUserCount = verifiedOrgs.map((org, idx) => ({ + ...org, + activeMembers: verifiedOrgUsers[idx]?.filter((org) => !org.inactive).length ?? 0 + })) - const highestTierOrgs = eligibleOrgs.filter(isValid).reduce((acc, org) => { - if (acc.length === 0) { - return [org] - } - const highestTier = acc[0]!.tier - if (org.tier === highestTier) { - return [...acc, org] - } - if (org.tier === 'enterprise') { - return [org] - } - if (highestTier === 'starter' && org.tier === 'team') { - return [org] - } - return acc - }, [] as OrgWithActiveMembers[]) + const highestTierOrgs = verifiedOrgsWithActiveUserCount.reduce( + (acc, org) => { + if (acc.length === 0) { + return [org] + } + const highestTier = acc[0]!.tier + if (org.tier === highestTier) { + return [...acc, org] + } + if (org.tier === 'enterprise') { + return [org] + } + if (highestTier === 'starter' && org.tier === 'team') { + return [org] + } + return acc + }, + [] as typeof verifiedOrgsWithActiveUserCount + ) const biggestSize = highestTierOrgs.reduce( (acc, org) => (org.activeMembers > acc ? org.activeMembers : acc), diff --git a/packages/server/utils/setTierForOrgUsers.ts b/packages/server/utils/setTierForOrgUsers.ts index 3477e29b2ad..0983faae06d 100644 --- a/packages/server/utils/setTierForOrgUsers.ts +++ b/packages/server/utils/setTierForOrgUsers.ts @@ -8,25 +8,25 @@ * will be created. */ import getRethink from '../database/rethinkDriver' -import {TierEnum} from '../database/types/Invoice' +import getKysely from '../postgres/getKysely' const setTierForOrgUsers = async (orgId: string) => { const r = await getRethink() + const organization = await getKysely() + .selectFrom('Organization') + .select(['trialStartDate', 'tier']) + .where('id', '=', orgId) + .executeTakeFirstOrThrow() + const {tier, trialStartDate} = organization + await r .table('OrganizationUser') .getAll(orgId, {index: 'orgId'}) .filter({removedAt: null}) - .update( - { - tier: r.table('Organization').get(orgId).getField('tier') as unknown as TierEnum, - trialStartDate: r - .table('Organization') - .get(orgId) - .getField('trialStartDate') - .default(null) as unknown as Date | null - }, - {nonAtomic: true} - ) + .update({ + tier, + trialStartDate + }) .run() } diff --git a/packages/server/utils/setUserTierForUserIds.ts b/packages/server/utils/setUserTierForUserIds.ts index 880f2f23996..4f0b05f5381 100644 --- a/packages/server/utils/setUserTierForUserIds.ts +++ b/packages/server/utils/setUserTierForUserIds.ts @@ -1,58 +1,67 @@ import getRethink from '../database/rethinkDriver' -import {RDatum} from '../database/stricterR' -import OrganizationUser from '../database/types/OrganizationUser' -import {TierEnum} from '../postgres/queries/generated/updateUserQuery' -import {getUsersByIds} from '../postgres/queries/getUsersByIds' -import updateUserTiers from '../postgres/queries/updateUserTiers' +import isValid from '../graphql/isValid' +import getKysely from '../postgres/getKysely' import {analytics} from './analytics/analytics' +// MK: this is crazy spaghetti & needs to go away. See https://github.com/ParabolInc/parabol/issues/9932 + // This doesn't actually read any tier/trial fields on the 'OrganizationUser' object - these fields // come directly from 'Organization' instead. As a result, this can be run in parallel with // 'setTierForOrgUsers'. -const setUserTierForUserIds = async (userIds: string[]) => { + +const setUserTierForUserId = async (userId: string) => { const r = await getRethink() - const userTiers = (await r + const pg = getKysely() + + const orgUsers = await r .table('OrganizationUser') - .getAll(r.args(userIds), {index: 'userId'}) + .getAll(userId, {index: 'userId'}) .filter({removedAt: null}) - .merge((orgUser: RDatum) => ({ - tier: r.table('Organization').get(orgUser('orgId'))('tier').default('starter'), - trialStartDate: r.table('Organization').get(orgUser('orgId'))('trialStartDate').default(null) - })) - .group('userId') - .ungroup() - .map((row) => ({ - id: row('group'), - tier: r.branch( - row('reduction')('tier').contains('enterprise'), - 'enterprise', - row('reduction')('tier').contains('team'), - 'team', - 'starter' - ), - trialStartDate: r.max(row('reduction')('trialStartDate')) - })) - .run()) as {id: string; tier: TierEnum; trialStartDate: string | null}[] - - const userUpdates = userIds.map((userId) => { - const userTier = userTiers.find((userTier) => userTier.id === userId) - return { - id: userId, - tier: userTier ? userTier.tier : 'starter', - trialStartDate: userTier ? userTier.trialStartDate : null - } - }) - await updateUserTiers({users: userUpdates}) - - const users = await getUsersByIds(userIds) - users.forEach((user) => { - user && - analytics.identify({ - userId: user.id, - email: user.email, - highestTier: user.tier - }) + .run() + + const orgIds = orgUsers.map((orgUser) => orgUser.orgId) + + const organizations = await pg + .selectFrom('Organization') + .select(['trialStartDate', 'tier']) + .where('id', 'in', orgIds) + .execute() + + const allTiers = organizations.map((org) => org.tier) + const allTrialStartDates = organizations + .map((org) => org.trialStartDate?.getTime()) + .filter(isValid) + const maxTrialStartDate = Math.max(...allTrialStartDates) + const trialStartDate = maxTrialStartDate > 0 ? new Date(maxTrialStartDate) : null + const highestTier = allTiers.includes('enterprise') + ? 'enterprise' + : allTiers.includes('team') + ? 'team' + : 'starter' + + await pg + .updateTable('User') + .set({ + tier: highestTier, + trialStartDate + }) + .where('id', '=', userId) + .execute() + const user = await pg + .selectFrom('User') + .select('email') + .where('id', '=', userId) + .executeTakeFirstOrThrow() + + analytics.identify({ + userId, + email: user.email, + highestTier }) } +const setUserTierForUserIds = async (userIds: string[]) => { + return await Promise.all(userIds.map(setUserTierForUserId)) +} + export default setUserTierForUserIds diff --git a/scripts/toolboxSrc/setIsEnterprise.ts b/scripts/toolboxSrc/setIsEnterprise.ts index 4fe84a5242b..53e9ebf370b 100644 --- a/scripts/toolboxSrc/setIsEnterprise.ts +++ b/scripts/toolboxSrc/setIsEnterprise.ts @@ -16,21 +16,13 @@ export default async function setIsEnterprise() { 'Updating tier to "enterprise" for Organization and OrganizationUser tables in RethinkDB' ) - type RethinkTableKey = 'Organization' | 'OrganizationUser' - - const tablesToUpdate: RethinkTableKey[] = ['Organization', 'OrganizationUser'] await getKysely().updateTable('Organization').set({tier: 'enterprise'}).execute() - const rethinkPromises = tablesToUpdate.map(async (table) => { - const result = await r - .table(table) - .update({ - tier: 'enterprise' - }) - .run() - - console.log(`Updated ${result.replaced} rows in ${table} table in RethinkDB.`) - return result - }) + await r + .table('OrganizationUser') + .update({ + tier: 'enterprise' + }) + .run() const pg = getPg() @@ -50,7 +42,7 @@ export default async function setIsEnterprise() { const pgPromises = [updateUserPromise, updateTeamPromise] - await Promise.all([...rethinkPromises, ...pgPromises]) + await Promise.all(pgPromises) console.log('Finished updating tiers.') From af856f28dde2e46c477cd38012c04a37d0751d76 Mon Sep 17 00:00:00 2001 From: Matt Krick Date: Mon, 8 Jul 2024 14:42:49 -0700 Subject: [PATCH 30/47] fix: isRequestToJoin tests Signed-off-by: Matt Krick --- packages/server/__tests__/common.ts | 24 + packages/server/__tests__/globalSetup.ts | 8 +- .../__tests__/isOrgVerified.test.ts | 11 +- packages/server/postgres/getKysely.ts | 10 +- packages/server/postgres/getPg.ts | 9 +- .../isRequestToJoinDomainAllowed.test.ts | 553 ++++++------------ .../utils/isRequestToJoinDomainAllowed.ts | 5 +- 7 files changed, 229 insertions(+), 391 deletions(-) diff --git a/packages/server/__tests__/common.ts b/packages/server/__tests__/common.ts index 93c134b229b..71e00b8a71a 100644 --- a/packages/server/__tests__/common.ts +++ b/packages/server/__tests__/common.ts @@ -1,8 +1,10 @@ import base64url from 'base64url' import crypto from 'crypto' import faker from 'faker' +import {sql} from 'kysely' import getRethink from '../database/rethinkDriver' import ServerAuthToken from '../database/types/ServerAuthToken' +import getKysely from '../postgres/getKysely' import encodeAuthToken from '../utils/encodeAuthToken' const HOST = process.env.GRAPHQL_HOST || 'localhost:3000' @@ -201,3 +203,25 @@ export const getUserTeams = async (userId: string) => { }) return user.data.user.teams as [{id: string}, ...{id: string}[]] } + +export const createPGTables = async (...tables: string[]) => { + const pg = getKysely() + await Promise.all( + tables.map(async (table) => { + return sql` + CREATE TABLE IF NOT EXISTS ${sql.table(table)} (like "public".${sql.table(table)} including ALL)`.execute( + pg + ) + }) + ) + await truncatePGTables(...tables) +} + +export const truncatePGTables = async (...tables: string[]) => { + const pg = getKysely() + await Promise.all( + tables.map(async (table) => { + return sql`TRUNCATE TABLE ${sql.table(table)} CASCADE`.execute(pg) + }) + ) +} diff --git a/packages/server/__tests__/globalSetup.ts b/packages/server/__tests__/globalSetup.ts index 69df2a89fdd..6cecd76a0f3 100644 --- a/packages/server/__tests__/globalSetup.ts +++ b/packages/server/__tests__/globalSetup.ts @@ -1,18 +1,12 @@ import '../../../scripts/webpack/utils/dotenv' import getRethink from '../database/rethinkDriver' -import getKysely from '../postgres/getKysely' async function setup() { const r = await getRethink() - const pg = getKysely() // The IP address is always localhost // so the safety checks will eventually fail if run too much - await Promise.all([ - pg.deleteFrom('FailedAuthRequest').execute(), - r.table('PasswordResetRequest').delete().run(), - pg.deleteFrom('SAMLDomain').where('domain', '=', 'example.com').execute() - ]) + await Promise.all([r.table('PasswordResetRequest').delete().run()]) } export default setup diff --git a/packages/server/dataloader/__tests__/isOrgVerified.test.ts b/packages/server/dataloader/__tests__/isOrgVerified.test.ts index d75386eeaa1..c4902008f64 100644 --- a/packages/server/dataloader/__tests__/isOrgVerified.test.ts +++ b/packages/server/dataloader/__tests__/isOrgVerified.test.ts @@ -1,6 +1,7 @@ /* eslint-env jest */ import {sql} from 'kysely' import {r} from 'rethinkdb-ts' +import {createPGTables} from '../../__tests__/common' import getRethinkConfig from '../../database/getRethinkConfig' import getRethink from '../../database/rethinkDriver' import OrganizationUser from '../../database/types/OrganizationUser' @@ -30,15 +31,6 @@ const testConfig = { db: TEST_DB } -export const createPGTables = async (...tables: string[]) => { - const pg = getKysely() - return tables.map((table) => { - sql`CREATE TABLE ${sql.table(table)} (LIKE "public".${sql.table(table)} INCLUDING ALL);`.execute( - pg - ) - }) -} - const createTables = async (...tables: string[]) => { for (const tableName of tables) { const structure = await r @@ -131,7 +123,6 @@ beforeAll(async () => { //ignore } await pg.schema.createSchema(TEST_DB).ifNotExists().execute() - sql`SET search_path TO '${TEST_DB}'`.execute(pg) await r.dbCreate(TEST_DB).run() await createPGTables('Organization') diff --git a/packages/server/postgres/getKysely.ts b/packages/server/postgres/getKysely.ts index 0a3256081d3..c7c02a59d16 100644 --- a/packages/server/postgres/getKysely.ts +++ b/packages/server/postgres/getKysely.ts @@ -4,9 +4,9 @@ import {DB} from './pg.d' let kysely: Kysely | undefined -const makeKysely = () => { - const nextPg = getPg() - nextPg.on('poolChange' as any, makeKysely) +const makeKysely = (schema?: string) => { + const nextPg = getPg(schema) + nextPg.on('poolChange' as any, () => makeKysely(schema)) return new Kysely({ dialect: new PostgresDialect({ pool: nextPg @@ -20,9 +20,9 @@ const makeKysely = () => { }) } -const getKysely = () => { +const getKysely = (schema?: string) => { if (!kysely) { - kysely = makeKysely() + kysely = makeKysely(schema) } return kysely } diff --git a/packages/server/postgres/getPg.ts b/packages/server/postgres/getPg.ts index 2edc5ff4729..8628189c615 100644 --- a/packages/server/postgres/getPg.ts +++ b/packages/server/postgres/getPg.ts @@ -22,10 +22,17 @@ const graceFullyReconnect = async () => { } let pool: Pool | undefined -const getPg = () => { +const getPg = (schema?: string) => { if (!pool) { pool = new Pool(config) pool.on('error', graceFullyReconnect) + if (schema) { + pool.on('connect', (client) => { + // passing the search_path as a connection option does not work + // The schema has to be explicitly each time that way. + client.query(`SET search_path TO "${schema}"`) + }) + } } return pool } diff --git a/packages/server/utils/__tests__/isRequestToJoinDomainAllowed.test.ts b/packages/server/utils/__tests__/isRequestToJoinDomainAllowed.test.ts index 2739ab07490..3c75a522b73 100644 --- a/packages/server/utils/__tests__/isRequestToJoinDomainAllowed.test.ts +++ b/packages/server/utils/__tests__/isRequestToJoinDomainAllowed.test.ts @@ -1,16 +1,18 @@ /* eslint-env jest */ -import {sql} from 'kysely' +import {Insertable} from 'kysely' import {r} from 'rethinkdb-ts' +import {createPGTables, truncatePGTables} from '../../__tests__/common' import getRethinkConfig from '../../database/getRethinkConfig' import getRethink from '../../database/rethinkDriver' import {TierEnum} from '../../database/types/Invoice' import OrganizationUser from '../../database/types/OrganizationUser' -import {createPGTables} from '../../dataloader/__tests__/isOrgVerified.test' +import RootDataLoader from '../../dataloader/RootDataLoader' import generateUID from '../../generateUID' -import {DataLoaderWorker} from '../../graphql/graphql' import getKysely from '../../postgres/getKysely' +import {User} from '../../postgres/pg' import getRedis from '../getRedis' import {getEligibleOrgIdsByDomain} from '../isRequestToJoinDomainAllowed' + jest.mock('../../database/rethinkDriver') jest.mocked(getRethink).mockImplementation(() => { @@ -45,6 +47,10 @@ type TestOrganizationUser = Partial< Pick > +type TestUser = Insertable +const addUsers = async (users: TestUser[]) => { + getKysely().insertInto('User').values(users).execute() +} const addOrg = async ( activeDomain: string | null, members: TestOrganizationUser[], @@ -73,46 +79,22 @@ const addOrg = async ( return orgId } -const userLoader = { - load: jest.fn(), - loadMany: jest.fn() -} -userLoader.loadMany.mockReturnValue([]) - -const isCompanyDomainLoader = { - load: jest.fn(), - loadMany: jest.fn() -} -isCompanyDomainLoader.load.mockReturnValue(true) - -const dataLoader = { - get: jest.fn((loader) => { - const loaders = { - users: userLoader, - isCompanyDomain: isCompanyDomainLoader - } - return loaders[loader as keyof typeof loaders] - }) -} as any as DataLoaderWorker - beforeAll(async () => { await r.connectPool(testConfig) - const pg = getKysely() + const pg = getKysely(TEST_DB) try { await r.dbDrop(TEST_DB).run() } catch (e) { //ignore } await pg.schema.createSchema(TEST_DB).ifNotExists().execute() - sql`SET search_path TO '${TEST_DB}'`.execute(pg) await r.dbCreate(TEST_DB).run() - await createPGTables('Organization') + await createPGTables('Organization', 'User', 'FreemailDomain') await createTables('OrganizationUser') }) afterEach(async () => { - const pg = getKysely() - await sql`truncate table ${sql.table('Organization')}`.execute(pg) + await truncatePGTables('Organization', 'User') await r.table('OrganizationUser').delete().run() }) @@ -121,254 +103,81 @@ afterAll(async () => { getRedis().quit() }) -test('Founder is billing lead', async () => { - await addOrg('parabol.co', [ - { - joinedAt: new Date('2023-09-06'), - role: 'BILLING_LEADER', - userId: 'user1' - }, - { - joinedAt: new Date('2023-09-12'), - userId: 'user2' - } - ]) - - await getEligibleOrgIdsByDomain('parabol.co', 'newUser', dataLoader) - expect(userLoader.loadMany).toHaveBeenCalledTimes(1) - expect(userLoader.loadMany).toHaveBeenCalledWith(['user1']) -}) - -test('Org with noPromptToJoinOrg feature flag is ignored', async () => { - await addOrg( - 'parabol.co', - [ - { - joinedAt: new Date('2023-09-06'), - role: 'BILLING_LEADER', - userId: 'user1' - }, - { - joinedAt: new Date('2023-09-12'), - userId: 'user2' - } - ], - {featureFlags: ['noPromptToJoinOrg']} - ) - - await getEligibleOrgIdsByDomain('parabol.co', 'newUser', dataLoader) - expect(userLoader.loadMany).toHaveBeenCalledTimes(0) -}) - -test('Inactive founder is ignored', async () => { - await addOrg('parabol.co', [ - { - joinedAt: new Date('2023-09-06'), - role: 'BILLING_LEADER', - userId: 'founder1', - inactive: true - }, - { - joinedAt: new Date('2023-09-12'), - userId: 'member1' - }, - { - joinedAt: new Date('2023-09-12'), - userId: 'member2' - } - ]) - - await getEligibleOrgIdsByDomain('parabol.co', 'newUser', dataLoader) - // implementation detail, important is only that no user was loaded - expect(userLoader.loadMany).toHaveBeenCalledTimes(1) - expect(userLoader.loadMany).toHaveBeenCalledWith([]) -}) - -test('Non-founder billing lead is checked', async () => { +test('Only the biggest org with verified emails qualify', async () => { await addOrg('parabol.co', [ { joinedAt: new Date('2023-09-06'), - role: 'BILLING_LEADER', userId: 'founder1', - inactive: true - }, - { - joinedAt: new Date('2023-09-12'), - role: 'BILLING_LEADER', - userId: 'billing1' + role: 'BILLING_LEADER' }, { - joinedAt: new Date('2023-09-12'), + joinedAt: new Date('2023-09-07'), userId: 'member1' } ]) - - await getEligibleOrgIdsByDomain('parabol.co', 'newUser', dataLoader) - expect(userLoader.loadMany).toHaveBeenCalledTimes(1) - expect(userLoader.loadMany).toHaveBeenCalledWith(['billing1']) -}) - -test('Founder is checked even when not billing lead', async () => { - await addOrg('parabol.co', [ - { - joinedAt: new Date('2023-09-06'), - userId: 'user1' - }, - { - joinedAt: new Date('2023-09-12'), - userId: 'user2' - } - ]) - - await getEligibleOrgIdsByDomain('parabol.co', 'newUser', dataLoader) - expect(userLoader.loadMany).toHaveBeenCalledTimes(1) - expect(userLoader.loadMany).toHaveBeenCalledWith(['user1']) -}) - -test('All matching orgs are checked', async () => { - await addOrg('parabol.co', [ + const biggerOrg = await addOrg('parabol.co', [ { joinedAt: new Date('2023-09-06'), - userId: 'founder1' + userId: 'founder2', + role: 'BILLING_LEADER' }, { joinedAt: new Date('2023-09-07'), - userId: 'member1' - } - ]) - await addOrg('parabol.co', [ - { - joinedAt: new Date('2023-09-12'), - userId: 'founder2' - }, - { - joinedAt: new Date('2023-09-13'), userId: 'member2' - } - ]) - - await getEligibleOrgIdsByDomain('parabol.co', 'newUser', dataLoader) - // implementation detail, important is only that both users were loaded - expect(userLoader.loadMany).toHaveBeenCalledTimes(2) - expect(userLoader.loadMany).toHaveBeenCalledWith(['founder1']) - expect(userLoader.loadMany).toHaveBeenCalledWith(['founder2']) -}) - -test('Empty org does not throw', async () => { - await addOrg('parabol.co', []) - - await getEligibleOrgIdsByDomain('parabol.co', 'newUser', dataLoader) - expect(userLoader.loadMany).toHaveBeenCalledTimes(0) -}) - -test('No org does not throw', async () => { - await getEligibleOrgIdsByDomain('example.com', 'newUser', dataLoader) - expect(userLoader.loadMany).toHaveBeenCalledTimes(0) -}) - -test('1 person orgs are ignored', async () => { - await addOrg('parabol.co', [ - { - joinedAt: new Date('2023-09-06'), - role: 'BILLING_LEADER', - userId: 'founder1' - } - ]) - - await getEligibleOrgIdsByDomain('parabol.co', 'newUser', dataLoader) - expect(userLoader.loadMany).toHaveBeenCalledTimes(0) -}) - -test('Org matching the user are ignored', async () => { - await addOrg('parabol.co', [ - { - joinedAt: new Date('2023-09-06'), - userId: 'user1' }, { - joinedAt: new Date('2023-09-06'), - userId: 'newUser' + joinedAt: new Date('2023-09-07'), + userId: 'member3' } ]) - - await getEligibleOrgIdsByDomain('parabol.co', 'newUser', dataLoader) - expect(userLoader.loadMany).toHaveBeenCalledTimes(0) -}) - -test('Only the biggest org with verified emails qualify', async () => { await addOrg('parabol.co', [ { joinedAt: new Date('2023-09-06'), - userId: 'founder1' + userId: 'founder3', + role: 'BILLING_LEADER' }, { joinedAt: new Date('2023-09-07'), - userId: 'member1' + userId: 'member3' } ]) - const biggerOrg = await addOrg('parabol.co', [ + addUsers([ { - joinedAt: new Date('2023-09-06'), - userId: 'founder2' - }, - { - joinedAt: new Date('2023-09-07'), - userId: 'member2' + id: 'founder1', + email: 'user1@parabol.co', + picture: '', + preferredName: 'user1', + identities: [ + { + isEmailVerified: true + } + ] }, { - joinedAt: new Date('2023-09-07'), - userId: 'member3' - } - ]) - await addOrg('parabol.co', [ - { - joinedAt: new Date('2023-09-06'), - userId: 'founder3' + id: 'founder2', + email: 'user2@parabol.co', + picture: '', + preferredName: 'user2', + identities: [ + { + isEmailVerified: true + } + ] }, { - joinedAt: new Date('2023-09-07'), - userId: 'member3' + id: 'founder3', + email: 'user3@parabol.co', + picture: '', + preferredName: 'user3', + identities: [ + { + isEmailVerified: false + } + ] } ]) - - userLoader.loadMany.mockImplementation((userIds) => { - const users = { - founder1: { - email: 'user1@parabol.co', - identities: [ - { - isEmailVerified: true - } - ] - }, - founder2: { - email: 'user2@parabol.co', - identities: [ - { - isEmailVerified: true - } - ] - }, - founder3: { - email: 'user3@parabol.co', - identities: [ - { - isEmailVerified: false - } - ] - } - } - return userIds.map((id: keyof typeof users) => ({ - id, - ...users[id] - })) - }) - + const dataLoader = new RootDataLoader() const orgIds = await getEligibleOrgIdsByDomain('parabol.co', 'newUser', dataLoader) - expect(userLoader.loadMany).toHaveBeenCalledTimes(3) - expect(userLoader.loadMany).toHaveBeenCalledWith(['founder1']) - expect(userLoader.loadMany).toHaveBeenCalledWith(['founder2']) - expect(userLoader.loadMany).toHaveBeenCalledWith(['founder3']) expect(orgIds).toIncludeSameMembers([biggerOrg]) }) @@ -376,7 +185,8 @@ test('All the biggest orgs with verified emails qualify', async () => { const org1 = await addOrg('parabol.co', [ { joinedAt: new Date('2023-09-06'), - userId: 'founder1' + userId: 'founder1', + role: 'BILLING_LEADER' }, { joinedAt: new Date('2023-09-07'), @@ -386,7 +196,8 @@ test('All the biggest orgs with verified emails qualify', async () => { const org2 = await addOrg('parabol.co', [ { joinedAt: new Date('2023-09-06'), - userId: 'founder2' + userId: 'founder2', + role: 'BILLING_LEADER' }, { joinedAt: new Date('2023-09-07'), @@ -396,52 +207,52 @@ test('All the biggest orgs with verified emails qualify', async () => { await addOrg('parabol.co', [ { joinedAt: new Date('2023-09-06'), - userId: 'founder3' + userId: 'founder3', + role: 'BILLING_LEADER' }, { joinedAt: new Date('2023-09-07'), userId: 'member3' } ]) - - userLoader.loadMany.mockImplementation((userIds) => { - const users = { - founder1: { - email: 'user1@parabol.co', - identities: [ - { - isEmailVerified: true - } - ] - }, - founder2: { - email: 'user2@parabol.co', - identities: [ - { - isEmailVerified: true - } - ] - }, - founder3: { - email: 'user3@parabol.co', - identities: [ - { - isEmailVerified: false - } - ] - } + await addUsers([ + { + id: 'founder1', + email: 'user1@parabol.co', + picture: '', + preferredName: 'user1', + identities: [ + { + isEmailVerified: true + } + ] + }, + { + id: 'founder2', + email: 'user2@parabol.co', + picture: '', + preferredName: 'user2', + identities: [ + { + isEmailVerified: true + } + ] + }, + { + id: 'founder3', + email: 'user3@parabol.co', + picture: '', + preferredName: 'user3', + identities: [ + { + isEmailVerified: false + } + ] } - return userIds.map((id: keyof typeof users) => ({ - id, - ...users[id] - })) - }) + ]) + const dataLoader = new RootDataLoader() const orgIds = await getEligibleOrgIdsByDomain('parabol.co', 'newUser', dataLoader) - expect(userLoader.loadMany).toHaveBeenCalledTimes(3) - expect(userLoader.loadMany).toHaveBeenCalledWith(['founder1']) - expect(userLoader.loadMany).toHaveBeenCalledWith(['founder2']) - expect(userLoader.loadMany).toHaveBeenCalledWith(['founder3']) expect(orgIds).toIncludeSameMembers([org1, org2]) }) @@ -451,7 +262,8 @@ test('Team trumps starter tier with more users org', async () => { [ { joinedAt: new Date('2023-09-06'), - userId: 'founder1' + userId: 'founder1', + role: 'BILLING_LEADER' }, { joinedAt: new Date('2023-09-07'), @@ -463,7 +275,8 @@ test('Team trumps starter tier with more users org', async () => { await addOrg('parabol.co', [ { joinedAt: new Date('2023-09-06'), - userId: 'founder2' + userId: 'founder2', + role: 'BILLING_LEADER' }, { joinedAt: new Date('2023-09-07'), @@ -477,7 +290,8 @@ test('Team trumps starter tier with more users org', async () => { await addOrg('parabol.co', [ { joinedAt: new Date('2023-09-06'), - userId: 'founder3' + userId: 'founder3', + role: 'BILLING_LEADER' }, { joinedAt: new Date('2023-09-07'), @@ -485,44 +299,43 @@ test('Team trumps starter tier with more users org', async () => { } ]) - userLoader.loadMany.mockImplementation((userIds) => { - const users = { - founder1: { - email: 'user1@parabol.co', - identities: [ - { - isEmailVerified: true - } - ] - }, - founder2: { - email: 'user2@parabol.co', - identities: [ - { - isEmailVerified: true - } - ] - }, - founder3: { - email: 'user3@parabol.co', - identities: [ - { - isEmailVerified: false - } - ] - } + await addUsers([ + { + id: 'founder1', + email: 'user1@parabol.co', + picture: '', + preferredName: 'user1', + identities: [ + { + isEmailVerified: true + } + ] + }, + { + id: 'founder2', + email: 'user2@parabol.co', + picture: '', + preferredName: 'user2', + identities: [ + { + isEmailVerified: true + } + ] + }, + { + id: 'founder3', + email: 'user3@parabol.co', + picture: '', + preferredName: 'user3', + identities: [ + { + isEmailVerified: false + } + ] } - return userIds.map((id: keyof typeof users) => ({ - id, - ...users[id] - })) - }) - + ]) + const dataLoader = new RootDataLoader() const orgIds = await getEligibleOrgIdsByDomain('parabol.co', 'newUser', dataLoader) - expect(userLoader.loadMany).toHaveBeenCalledTimes(3) - expect(userLoader.loadMany).toHaveBeenCalledWith(['founder1']) - expect(userLoader.loadMany).toHaveBeenCalledWith(['founder2']) - expect(userLoader.loadMany).toHaveBeenCalledWith(['founder3']) expect(orgIds).toIncludeSameMembers([teamOrg]) }) @@ -532,7 +345,8 @@ test('Enterprise trumps team tier with more users org', async () => { [ { joinedAt: new Date('2023-09-06'), - userId: 'founder1' + userId: 'founder1', + role: 'BILLING_LEADER' }, { joinedAt: new Date('2023-09-07'), @@ -546,7 +360,8 @@ test('Enterprise trumps team tier with more users org', async () => { [ { joinedAt: new Date('2023-09-06'), - userId: 'founder2' + userId: 'founder2', + role: 'BILLING_LEADER' }, { joinedAt: new Date('2023-09-07'), @@ -562,7 +377,8 @@ test('Enterprise trumps team tier with more users org', async () => { await addOrg('parabol.co', [ { joinedAt: new Date('2023-09-06'), - userId: 'founder3' + userId: 'founder3', + role: 'BILLING_LEADER' }, { joinedAt: new Date('2023-09-07'), @@ -570,44 +386,43 @@ test('Enterprise trumps team tier with more users org', async () => { } ]) - userLoader.loadMany.mockImplementation((userIds) => { - const users = { - founder1: { - email: 'user1@parabol.co', - identities: [ - { - isEmailVerified: true - } - ] - }, - founder2: { - email: 'user2@parabol.co', - identities: [ - { - isEmailVerified: true - } - ] - }, - founder3: { - email: 'user3@parabol.co', - identities: [ - { - isEmailVerified: false - } - ] - } + await addUsers([ + { + id: 'founder1', + email: 'user1@parabol.co', + picture: '', + preferredName: 'user1', + identities: [ + { + isEmailVerified: true + } + ] + }, + { + id: 'founder2', + email: 'user2@parabol.co', + picture: '', + preferredName: 'user2', + identities: [ + { + isEmailVerified: true + } + ] + }, + { + id: 'founder3', + email: 'user3@parabol.co', + picture: '', + preferredName: 'user3', + identities: [ + { + isEmailVerified: false + } + ] } - return userIds.map((id: keyof typeof users) => ({ - id, - ...users[id] - })) - }) - + ]) + const dataLoader = new RootDataLoader() const orgIds = await getEligibleOrgIdsByDomain('parabol.co', 'newUser', dataLoader) - expect(userLoader.loadMany).toHaveBeenCalledTimes(3) - expect(userLoader.loadMany).toHaveBeenCalledWith(['founder1']) - expect(userLoader.loadMany).toHaveBeenCalledWith(['founder2']) - expect(userLoader.loadMany).toHaveBeenCalledWith(['founder3']) expect(orgIds).toIncludeSameMembers([enterpriseOrg]) }) @@ -623,10 +438,12 @@ test('Orgs with verified emails from different domains do not qualify', async () } ]) - userLoader.loadMany.mockReturnValue([ + await addUsers([ { id: 'founder1', email: 'user1@parabol.fun', + picture: '', + preferredName: 'user1', identities: [ { isEmailVerified: true @@ -635,9 +452,8 @@ test('Orgs with verified emails from different domains do not qualify', async () } ]) + const dataLoader = new RootDataLoader() const orgIds = await getEligibleOrgIdsByDomain('parabol.co', 'newUser', dataLoader) - expect(userLoader.loadMany).toHaveBeenCalledTimes(1) - expect(userLoader.loadMany).toHaveBeenCalledWith(['founder1']) expect(orgIds).toIncludeSameMembers([]) }) @@ -660,10 +476,12 @@ test('Orgs with at least 1 verified billing lead with correct email qualify', as } ]) - userLoader.loadMany.mockReturnValue([ + await addUsers([ { id: 'user1', email: 'user1@parabol.fun', + preferredName: '', + picture: '', identities: [ { isEmailVerified: true @@ -673,6 +491,8 @@ test('Orgs with at least 1 verified billing lead with correct email qualify', as { id: 'user2', email: 'user2@parabol.fun', + preferredName: '', + picture: '', identities: [ { isEmailVerified: true @@ -682,6 +502,8 @@ test('Orgs with at least 1 verified billing lead with correct email qualify', as { id: 'user3', email: 'user3@parabol.co', + preferredName: '', + picture: '', identities: [ { isEmailVerified: true @@ -690,8 +512,7 @@ test('Orgs with at least 1 verified billing lead with correct email qualify', as } ]) + const dataLoader = new RootDataLoader() const orgIds = await getEligibleOrgIdsByDomain('parabol.co', 'newUser', dataLoader) - expect(userLoader.loadMany).toHaveBeenCalledTimes(1) - expect(userLoader.loadMany).toHaveBeenCalledWith(['user1', 'user2', 'user3']) expect(orgIds).toIncludeSameMembers([org1]) }) diff --git a/packages/server/utils/isRequestToJoinDomainAllowed.ts b/packages/server/utils/isRequestToJoinDomainAllowed.ts index adf5d0ff4b7..9a596296b24 100644 --- a/packages/server/utils/isRequestToJoinDomainAllowed.ts +++ b/packages/server/utils/isRequestToJoinDomainAllowed.ts @@ -1,11 +1,12 @@ import User from '../database/types/User' +import {DataLoaderInstance} from '../dataloader/RootDataLoader' import {DataLoaderWorker} from '../graphql/graphql' import isUserVerified from './isUserVerified' export const getEligibleOrgIdsByDomain = async ( activeDomain: string, userId: string, - dataLoader: DataLoaderWorker + dataLoader: DataLoaderInstance ) => { const isCompanyDomain = await dataLoader.get('isCompanyDomain').load(activeDomain) if (!isCompanyDomain) { @@ -57,7 +58,7 @@ export const getEligibleOrgIdsByDomain = async ( (acc, org) => (org.activeMembers > acc ? org.activeMembers : acc), 0 ) - + console.log({verifiedOrgs, biggestSize, highestTierOrgs, verifiedOrgsWithActiveUserCount}) return highestTierOrgs .filter(({activeMembers}) => activeMembers === biggestSize) .map(({id}) => id) From 129d1a9be0e97622e45d0c4f56b6723021f578aa Mon Sep 17 00:00:00 2001 From: Matt Krick Date: Mon, 8 Jul 2024 15:50:32 -0700 Subject: [PATCH 31/47] fix: isOrgVerified Signed-off-by: Matt Krick --- .../__tests__/isOrgVerified.test.ts | 119 +++++++----------- .../server/dataloader/customLoaderMakers.ts | 10 +- 2 files changed, 55 insertions(+), 74 deletions(-) diff --git a/packages/server/dataloader/__tests__/isOrgVerified.test.ts b/packages/server/dataloader/__tests__/isOrgVerified.test.ts index c4902008f64..d782c512694 100644 --- a/packages/server/dataloader/__tests__/isOrgVerified.test.ts +++ b/packages/server/dataloader/__tests__/isOrgVerified.test.ts @@ -1,17 +1,16 @@ /* eslint-env jest */ -import {sql} from 'kysely' +import {Insertable} from 'kysely' import {r} from 'rethinkdb-ts' -import {createPGTables} from '../../__tests__/common' +import {createPGTables, truncatePGTables} from '../../__tests__/common' import getRethinkConfig from '../../database/getRethinkConfig' import getRethink from '../../database/rethinkDriver' import OrganizationUser from '../../database/types/OrganizationUser' import generateUID from '../../generateUID' -import {DataLoaderWorker} from '../../graphql/graphql' import getKysely from '../../postgres/getKysely' +import {User} from '../../postgres/pg' import getRedis from '../../utils/getRedis' import isUserVerified from '../../utils/isUserVerified' import RootDataLoader from '../RootDataLoader' -import {isOrgVerified} from '../customLoaderMakers' jest.mock('../../database/rethinkDriver') jest.mock('../../utils/isUserVerified') @@ -31,6 +30,11 @@ const testConfig = { db: TEST_DB } +type TestUser = Insertable +const addUsers = async (users: TestUser[]) => { + getKysely().insertInto('User').values(users).execute() +} + const createTables = async (...tables: string[]) => { for (const tableName of tables) { const structure = await r @@ -53,26 +57,6 @@ type TestOrganizationUser = Partial< } > -const userLoader = { - load: jest.fn(), - loadMany: jest.fn() -} -const isCompanyDomainLoader = { - load: jest.fn(), - loadMany: jest.fn() -} -isCompanyDomainLoader.load.mockReturnValue(true) - -const dataLoader = { - get: jest.fn((loader) => { - const loaders = { - users: userLoader, - isCompanyDomain: isCompanyDomainLoader - } - return loaders[loader as keyof typeof loaders] - }) -} as any as DataLoaderWorker - const addOrg = async ( activeDomain: string | null, members: TestOrganizationUser[], @@ -100,19 +84,9 @@ const addOrg = async ( await pg.insertInto('Organization').values(org).execute() await r.table('OrganizationUser').insert(orgUsers).run() - const users = orgUsers.map(({userId, domain}) => ({ - id: userId, - domain: domain ?? activeDomain - })) - - userLoader.load.mockImplementation((userId) => users.find((u) => u.id === userId)) - userLoader.loadMany.mockImplementation((userIds) => userIds.map(userLoader.load)) - return orgId } -const isOrgVerifiedLoader = isOrgVerified(dataLoader as any as RootDataLoader) - beforeAll(async () => { await r.connectPool(testConfig) const pg = getKysely() @@ -130,10 +104,8 @@ beforeAll(async () => { }) afterEach(async () => { - const pg = getKysely() - await sql`truncate table ${sql.table('Organization')}`.execute(pg) + await truncatePGTables('Organization', 'User') await r.table('OrganizationUser').delete().run() - isOrgVerifiedLoader.clearAll() }) afterAll(async () => { @@ -149,30 +121,37 @@ test('Founder is billing lead', async () => { userId: 'user1' } ]) - - const isVerified = await isOrgVerifiedLoader.load('parabol.co') + await addUsers([ + { + id: 'user1', + email: 'user1@parabol.co', + picture: '', + preferredName: '', + identities: [{isEmailVerified: true}] + } + ]) + const dataLoader = new RootDataLoader() + const isVerified = await dataLoader.get('isOrgVerified').load('parabol.co') expect(isVerified).toBe(true) }) -test('Inactive founder is ignored', async () => { - await addOrg('parabol.co', [ +test('Non-founder billing lead is checked', async () => { + await addUsers([ { - joinedAt: new Date('2023-09-06'), - role: 'BILLING_LEADER', - userId: 'founder1', - inactive: true + id: 'founder1', + email: 'user1@parabol.co', + picture: '', + preferredName: '', + identities: [{isEmailVerified: true}] }, { - joinedAt: new Date('2023-09-12'), - userId: 'member1' + id: 'billing1', + email: 'billing1@parabol.co', + picture: '', + preferredName: '', + identities: [{isEmailVerified: true}] } ]) - - const isVerified = await isOrgVerifiedLoader.load('parabol.co') - expect(isVerified).toBe(false) -}) - -test('Non-founder billing lead is checked', async () => { await addOrg('parabol.co', [ { joinedAt: new Date('2023-09-06'), @@ -191,34 +170,29 @@ test('Non-founder billing lead is checked', async () => { } ]) - const isVerified = await isOrgVerifiedLoader.load('parabol.co') - expect(isVerified).toBe(true) -}) - -test('Founder is checked even when not billing lead', async () => { - await addOrg('parabol.co', [ - { - joinedAt: new Date('2023-09-06'), - userId: 'user1' - }, - { - joinedAt: new Date('2023-09-12'), - userId: 'user2' - } - ]) - - const isVerified = await isOrgVerifiedLoader.load('parabol.co') + const dataLoader = new RootDataLoader() + const isVerified = await dataLoader.get('isOrgVerified').load('parabol.co') expect(isVerified).toBe(true) }) test('Empty org does not throw', async () => { await addOrg('parabol.co', []) - const isVerified = await isOrgVerifiedLoader.load('parabol.co') + const dataLoader = new RootDataLoader() + const isVerified = await dataLoader.get('isOrgVerified').load('parabol.co') expect(isVerified).toBe(false) }) test('Orgs with verified emails from different domains do not qualify', async () => { + await addUsers([ + { + id: 'founder1', + email: 'user1@not-parabol.co', + picture: '', + preferredName: '', + identities: [{isEmailVerified: true}] + } + ]) await addOrg('parabol.co', [ { joinedAt: new Date('2023-09-06'), @@ -227,6 +201,7 @@ test('Orgs with verified emails from different domains do not qualify', async () } as any ]) - const isVerified = await isOrgVerifiedLoader.load('parabol.co') + const dataLoader = new RootDataLoader() + const isVerified = await dataLoader.get('isOrgVerified').load('parabol.co') expect(isVerified).toBe(false) }) diff --git a/packages/server/dataloader/customLoaderMakers.ts b/packages/server/dataloader/customLoaderMakers.ts index f06aecc3ebe..46a03715026 100644 --- a/packages/server/dataloader/customLoaderMakers.ts +++ b/packages/server/dataloader/customLoaderMakers.ts @@ -745,14 +745,20 @@ export const isOrgVerified = (parent: RootDataLoader) => { .filter(isValid) .flat() .filter(({role}) => role && ['BILLING_LEADER', 'ORG_ADMIN'].includes(role)) - const orgUsersUserIds = orgUsersWithRole.map((orgUser) => orgUser.userId) const usersRes = await parent.get('users').loadMany(orgUsersUserIds) const verifiedUsers = usersRes.filter(isValid).filter(isUserVerified) const verifiedOrgUsers = orgUsersWithRole.filter((orgUser) => verifiedUsers.some((user) => user.id === orgUser.userId) ) - return orgIds.map((orgId) => verifiedOrgUsers.some((orgUser) => orgUser.orgId === orgId)) + return await Promise.all( + orgIds.map(async (orgId) => { + const isUserVerified = verifiedOrgUsers.some((orgUser) => orgUser.orgId === orgId) + if (isUserVerified) return true + const isOrgSAML = await parent.get('samlByOrgId').load(orgId) + return !!isOrgSAML + }) + ) }, { ...parent.dataLoaderOptions From 6e368a476483e9b4752e18593ed1cf67d23d51b4 Mon Sep 17 00:00:00 2001 From: Matt Krick Date: Mon, 8 Jul 2024 16:08:28 -0700 Subject: [PATCH 32/47] fix deleteUser test Signed-off-by: Matt Krick --- packages/server/dataloader/__tests__/isOrgVerified.test.ts | 2 +- .../server/utils/__tests__/isRequestToJoinDomainAllowed.test.ts | 2 +- packages/server/utils/isRequestToJoinDomainAllowed.ts | 1 - packages/server/utils/setUserTierForUserIds.ts | 1 + 4 files changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/server/dataloader/__tests__/isOrgVerified.test.ts b/packages/server/dataloader/__tests__/isOrgVerified.test.ts index d782c512694..96a455b9767 100644 --- a/packages/server/dataloader/__tests__/isOrgVerified.test.ts +++ b/packages/server/dataloader/__tests__/isOrgVerified.test.ts @@ -99,7 +99,7 @@ beforeAll(async () => { await pg.schema.createSchema(TEST_DB).ifNotExists().execute() await r.dbCreate(TEST_DB).run() - await createPGTables('Organization') + await createPGTables('Organization', 'User', 'SAML', 'SAMLDomain') await createTables('OrganizationUser') }) diff --git a/packages/server/utils/__tests__/isRequestToJoinDomainAllowed.test.ts b/packages/server/utils/__tests__/isRequestToJoinDomainAllowed.test.ts index 3c75a522b73..73df49f1454 100644 --- a/packages/server/utils/__tests__/isRequestToJoinDomainAllowed.test.ts +++ b/packages/server/utils/__tests__/isRequestToJoinDomainAllowed.test.ts @@ -89,7 +89,7 @@ beforeAll(async () => { } await pg.schema.createSchema(TEST_DB).ifNotExists().execute() await r.dbCreate(TEST_DB).run() - await createPGTables('Organization', 'User', 'FreemailDomain') + await createPGTables('Organization', 'User', 'FreemailDomain', 'SAML', 'SAMLDomain') await createTables('OrganizationUser') }) diff --git a/packages/server/utils/isRequestToJoinDomainAllowed.ts b/packages/server/utils/isRequestToJoinDomainAllowed.ts index 9a596296b24..e04cf5de76d 100644 --- a/packages/server/utils/isRequestToJoinDomainAllowed.ts +++ b/packages/server/utils/isRequestToJoinDomainAllowed.ts @@ -58,7 +58,6 @@ export const getEligibleOrgIdsByDomain = async ( (acc, org) => (org.activeMembers > acc ? org.activeMembers : acc), 0 ) - console.log({verifiedOrgs, biggestSize, highestTierOrgs, verifiedOrgsWithActiveUserCount}) return highestTierOrgs .filter(({activeMembers}) => activeMembers === biggestSize) .map(({id}) => id) diff --git a/packages/server/utils/setUserTierForUserIds.ts b/packages/server/utils/setUserTierForUserIds.ts index 4f0b05f5381..6b22fff26a7 100644 --- a/packages/server/utils/setUserTierForUserIds.ts +++ b/packages/server/utils/setUserTierForUserIds.ts @@ -20,6 +20,7 @@ const setUserTierForUserId = async (userId: string) => { .run() const orgIds = orgUsers.map((orgUser) => orgUser.orgId) + if (orgIds.length === 0) return const organizations = await pg .selectFrom('Organization') From 27b0cf2e99b76f4effbe2ed5516f8c963886e5c9 Mon Sep 17 00:00:00 2001 From: Matt Krick Date: Tue, 9 Jul 2024 13:08:26 -0700 Subject: [PATCH 33/47] fix: comment Signed-off-by: Matt Krick --- packages/server/postgres/getPg.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/server/postgres/getPg.ts b/packages/server/postgres/getPg.ts index 8628189c615..0e82891eeb7 100644 --- a/packages/server/postgres/getPg.ts +++ b/packages/server/postgres/getPg.ts @@ -29,7 +29,7 @@ const getPg = (schema?: string) => { if (schema) { pool.on('connect', (client) => { // passing the search_path as a connection option does not work - // The schema has to be explicitly each time that way. + // That strategy requires explicitly stating the schema in each query client.query(`SET search_path TO "${schema}"`) }) } From 3c061e200ac93d3ff01ca48b5208681998af5df2 Mon Sep 17 00:00:00 2001 From: Matt Krick Date: Tue, 9 Jul 2024 13:49:18 -0700 Subject: [PATCH 34/47] fix: remove newUserUntil Signed-off-by: Matt Krick --- .../server/billing/helpers/adjustUserCount.ts | 24 ++++----- .../server/database/types/OrganizationUser.ts | 20 ++----- .../graphql/public/typeDefs/_legacy.graphql | 5 -- .../server/graphql/types/OrganizationUser.ts | 5 -- .../1720556055134_OrganizationUser-phase1.ts | 54 +++++++++++++++++++ 5 files changed, 69 insertions(+), 39 deletions(-) create mode 100644 packages/server/postgres/migrations/1720556055134_OrganizationUser-phase1.ts diff --git a/packages/server/billing/helpers/adjustUserCount.ts b/packages/server/billing/helpers/adjustUserCount.ts index 6fd57f11267..e4884d5a445 100644 --- a/packages/server/billing/helpers/adjustUserCount.ts +++ b/packages/server/billing/helpers/adjustUserCount.ts @@ -59,6 +59,12 @@ const changePause = (inactive: boolean) => async (_orgIds: string[], user: IUser }, userId ), + getKysely() + .updateTable('OrganizationUser') + .set({inactive}) + .where('userId', '=', userId) + .where('removedAt', 'is', null) + .execute(), r .table('OrganizationUser') .getAll(userId, {index: 'userId'}) @@ -73,12 +79,7 @@ const addUser = async (orgIds: string[], user: IUser, dataLoader: DataLoaderWork const r = await getRethink() const [rawOrganizations, organizationUsers] = await Promise.all([ dataLoader.get('organizations').loadMany(orgIds), - r - .table('OrganizationUser') - .getAll(userId, {index: 'userId'}) - .orderBy(r.desc('newUserUntil')) - .coerceTo('array') - .run() + dataLoader.get('organizationUsersByUserId').load(userId) ]) const organizations = rawOrganizations.filter(isValid) const docs = orgIds.map((orgId) => { @@ -87,19 +88,18 @@ const addUser = async (orgIds: string[], user: IUser, dataLoader: DataLoaderWork ) const organization = organizations.find((organization) => organization.id === orgId)! // continue the grace period from before, if any OR set to the end of the invoice OR (if it is a free account) no grace period - const newUserUntil = - (oldOrganizationUser && oldOrganizationUser.newUserUntil) || - organization.periodEnd || - new Date() return new OrganizationUser({ id: oldOrganizationUser?.id, orgId, userId, - newUserUntil, tier: organization.tier }) }) - + await getKysely() + .insertInto('OrganizationUser') + .values(docs) + .onConflict((oc) => oc.doNothing()) + .execute() await r.table('OrganizationUser').insert(docs, {conflict: 'replace'}).run() await Promise.all( orgIds.map((orgId) => { diff --git a/packages/server/database/types/OrganizationUser.ts b/packages/server/database/types/OrganizationUser.ts index 0d58058d67d..927242b2dc6 100644 --- a/packages/server/database/types/OrganizationUser.ts +++ b/packages/server/database/types/OrganizationUser.ts @@ -5,7 +5,6 @@ export type OrgUserRole = 'BILLING_LEADER' | 'ORG_ADMIN' interface Input { orgId: string userId: string - newUserUntil?: Date id?: string inactive?: boolean joinedAt?: Date @@ -20,36 +19,23 @@ export default class OrganizationUser { suggestedTier: TierEnum | null inactive: boolean joinedAt: Date - newUserUntil: Date orgId: string removedAt: Date | null role: OrgUserRole | null userId: string - tier: TierEnum | null + tier: TierEnum trialStartDate?: Date | null constructor(input: Input) { - const { - suggestedTier, - userId, - id, - removedAt, - inactive, - orgId, - joinedAt, - newUserUntil, - role, - tier - } = input + const {suggestedTier, userId, id, removedAt, inactive, orgId, joinedAt, role, tier} = input this.id = id || generateUID() this.suggestedTier = suggestedTier || null this.inactive = inactive || false this.joinedAt = joinedAt || new Date() - this.newUserUntil = newUserUntil || new Date() this.orgId = orgId this.removedAt = removedAt || null this.role = role || null this.userId = userId - this.tier = tier || null + this.tier = tier || 'starter' } } diff --git a/packages/server/graphql/public/typeDefs/_legacy.graphql b/packages/server/graphql/public/typeDefs/_legacy.graphql index 4b4a247f5b1..04f64657889 100644 --- a/packages/server/graphql/public/typeDefs/_legacy.graphql +++ b/packages/server/graphql/public/typeDefs/_legacy.graphql @@ -1994,11 +1994,6 @@ type OrganizationUser { """ joinedAt: DateTime! - """ - The last moment a billing leader can remove the user from the org & receive a refund. Set to the subscription periodEnd - """ - newUserUntil: DateTime! - """ FK """ diff --git a/packages/server/graphql/types/OrganizationUser.ts b/packages/server/graphql/types/OrganizationUser.ts index 942a57530a3..ec583e27372 100644 --- a/packages/server/graphql/types/OrganizationUser.ts +++ b/packages/server/graphql/types/OrganizationUser.ts @@ -24,11 +24,6 @@ const OrganizationUser = new GraphQLObjectType({ type: new GraphQLNonNull(GraphQLISO8601Type), description: 'the datetime the user first joined the org' }, - newUserUntil: { - type: new GraphQLNonNull(GraphQLISO8601Type), - description: - 'The last moment a billing leader can remove the user from the org & receive a refund. Set to the subscription periodEnd' - }, orgId: { type: new GraphQLNonNull(GraphQLID), description: 'FK' diff --git a/packages/server/postgres/migrations/1720556055134_OrganizationUser-phase1.ts b/packages/server/postgres/migrations/1720556055134_OrganizationUser-phase1.ts new file mode 100644 index 00000000000..adbf88b2279 --- /dev/null +++ b/packages/server/postgres/migrations/1720556055134_OrganizationUser-phase1.ts @@ -0,0 +1,54 @@ +import {Client} from 'pg' +import getPgConfig from '../getPgConfig' + +export async function up() { + const client = new Client(getPgConfig()) + await client.connect() + await client.query(` + DO $$ + BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'OrgUserRoleEnum') THEN + CREATE TYPE "OrgUserRoleEnum" AS ENUM ( + 'BILLING_LEADER', + 'ORG_ADMIN' + ); + END IF; + CREATE TABLE IF NOT EXISTS "OrganizationUser" ( + "id" VARCHAR(100) PRIMARY KEY, + "suggestedTier" "TierEnum", + "inactive" BOOLEAN NOT NULL DEFAULT FALSE, + "joinedAt" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(), + "newUserUntil" TIMESTAMP WITH TIME ZONE, + "orgId" VARCHAR(100) NOT NULL, + "removedAt" TIMESTAMP WITH TIME ZONE, + "role" "OrgUserRoleEnum", + "userId" VARCHAR(100) NOT NULL, + "tier" "TierEnum" NOT NULL, + "trialStartDate" TIMESTAMP WITH TIME ZONE, + CONSTRAINT "fk_userId" + FOREIGN KEY("userId") + REFERENCES "User"("id") + ON DELETE CASCADE, + CONSTRAINT "fk_orgId" + FOREIGN KEY("orgId") + REFERENCES "Organization"("id") + ON DELETE CASCADE + ); + CREATE INDEX IF NOT EXISTS "idx_OrganizationUser_tier" ON "OrganizationUser"("tier") WHERE "removedAt" IS NULL AND "inactive" = FALSE; + CREATE INDEX IF NOT EXISTS "idx_OrganizationUser_orgId" ON "OrganizationUser"("orgId") WHERE "removedAt" IS NULL; + CREATE INDEX IF NOT EXISTS "idx_OrganizationUser_userId" ON "OrganizationUser"("userId") WHERE "removedAt" IS NULL; + + END $$; +`) + await client.end() +} + +export async function down() { + const client = new Client(getPgConfig()) + await client.connect() + await client.query(` + DROP TABLE "OrganizationUser"; + DROP TYPE "OrgUserRoleEnum"; + ` /* Do undo magic */) + await client.end() +} From 1cdf979319aa9ab530b1a6b7cb948fe6258ec1d0 Mon Sep 17 00:00:00 2001 From: Matt Krick Date: Tue, 9 Jul 2024 14:46:48 -0700 Subject: [PATCH 35/47] chore: replace half of OrganizationUser instances Signed-off-by: Matt Krick --- .../server/billing/helpers/adjustUserCount.ts | 11 +++- .../server/billing/helpers/generateInvoice.ts | 14 ++--- .../handleEnterpriseOrgQuantityChanges.ts | 24 +++----- .../server/billing/helpers/teamLimitsCheck.ts | 14 +++++ .../helpers/updateSubscriptionQuantity.ts | 21 ++++++- .../__tests__/isOrgVerified.test.ts | 33 +++------- .../graphql/mutations/archiveOrganization.ts | 8 +++ .../graphql/mutations/helpers/createNewOrg.ts | 5 +- .../mutations/helpers/oldUpgradeToTeamTier.ts | 11 +--- .../mutations/helpers/removeFromOrg.ts | 39 +++++++----- .../server/graphql/mutations/moveTeamToOrg.ts | 60 +++++++------------ .../graphql/mutations/oldUpgradeToTeamTier.ts | 8 +++ 12 files changed, 130 insertions(+), 118 deletions(-) diff --git a/packages/server/billing/helpers/adjustUserCount.ts b/packages/server/billing/helpers/adjustUserCount.ts index e4884d5a445..4cb49b859d7 100644 --- a/packages/server/billing/helpers/adjustUserCount.ts +++ b/packages/server/billing/helpers/adjustUserCount.ts @@ -1,3 +1,4 @@ +import {sql} from 'kysely' import {InvoiceItemType} from 'parabol-client/types/constEnums' import getRethink from '../../database/rethinkDriver' import {RDatum} from '../../database/stricterR' @@ -52,7 +53,7 @@ const changePause = (inactive: boolean) => async (_orgIds: string[], user: IUser email, isActive: !inactive }) - return Promise.all([ + await Promise.all([ updateUser( { inactive @@ -111,7 +112,13 @@ const addUser = async (orgIds: string[], user: IUser, dataLoader: DataLoaderWork const deleteUser = async (orgIds: string[], user: IUser) => { const r = await getRethink() orgIds.forEach((orgId) => analytics.userRemovedFromOrg(user, orgId)) - return r + await getKysely() + .updateTable('OrganizationUser') + .set({removedAt: sql`CURRENT_TIMESTAMP`}) + .where('userId', '=', user.id) + .where('orgId', 'in', orgIds) + .execute() + await r .table('OrganizationUser') .getAll(user.id, {index: 'userId'}) .filter((row: RDatum) => r.expr(orgIds).contains(row('orgId'))) diff --git a/packages/server/billing/helpers/generateInvoice.ts b/packages/server/billing/helpers/generateInvoice.ts index cebf0d6c88f..b9cc7abb80b 100644 --- a/packages/server/billing/helpers/generateInvoice.ts +++ b/packages/server/billing/helpers/generateInvoice.ts @@ -1,7 +1,6 @@ import {InvoiceItemType} from 'parabol-client/types/constEnums' import Stripe from 'stripe' import getRethink from '../../database/rethinkDriver' -import {RDatum} from '../../database/stricterR' import Coupon from '../../database/types/Coupon' import Invoice, {InvoiceStatusEnum} from '../../database/types/Invoice' import {InvoiceLineItemEnum} from '../../database/types/InvoiceLineItem' @@ -353,16 +352,13 @@ export default async function generateInvoice( invoice.status === 'paid' && invoice.status_transitions.paid_at ? fromEpochSeconds(invoice.status_transitions.paid_at) : undefined - const [organization, billingLeaderIds] = await Promise.all([ + const [organization, orgUsers] = await Promise.all([ dataLoader.get('organizations').loadNonNull(orgId), - r - .table('OrganizationUser') - .getAll(orgId, {index: 'orgId'}) - .filter({removedAt: null}) - .filter((row: RDatum) => r.expr(['BILLING_LEADER', 'ORG_ADMIN']).contains(row('role'))) - .coerceTo('array')('userId') - .run() as any as string[] + dataLoader.get('organizationUsersByOrgId').load(orgId) ]) + const billingLeaderIds = orgUsers + .filter(({role}) => role && ['BILLING_LEADER', 'ORG_ADMIN'].includes(role)) + .map(({userId}) => userId) const billingLeaders = (await dataLoader.get('users').loadMany(billingLeaderIds)).filter(isValid) const billingLeaderEmails = billingLeaders.map((user) => user.email) diff --git a/packages/server/billing/helpers/handleEnterpriseOrgQuantityChanges.ts b/packages/server/billing/helpers/handleEnterpriseOrgQuantityChanges.ts index 215ac9396ef..e9cb414b876 100644 --- a/packages/server/billing/helpers/handleEnterpriseOrgQuantityChanges.ts +++ b/packages/server/billing/helpers/handleEnterpriseOrgQuantityChanges.ts @@ -1,5 +1,3 @@ -import getRethink from '../../database/rethinkDriver' -import {RDatum} from '../../database/stricterR' import {DataLoaderWorker} from '../../graphql/graphql' import {OrganizationSource} from '../../graphql/public/types/Organization' import {analytics} from '../../utils/analytics/analytics' @@ -9,31 +7,23 @@ const sendEnterpriseOverageEvent = async ( organization: OrganizationSource, dataLoader: DataLoaderWorker ) => { - const r = await getRethink() const manager = getStripeManager() const {id: orgId, stripeSubscriptionId} = organization if (!stripeSubscriptionId) return - const [orgUserCount, subscriptionItem] = await Promise.all([ - r - .table('OrganizationUser') - .getAll(orgId, {index: 'orgId'}) - .filter({removedAt: null, inactive: false}) - .count() - .run(), + const [orgUsers, subscriptionItem] = await Promise.all([ + dataLoader.get('organizationUsersByOrgId').load(orgId), manager.getSubscriptionItem(stripeSubscriptionId) ]) + const activeOrgUsers = orgUsers.filter(({inactive}) => !inactive) + const orgUserCount = activeOrgUsers.length if (!subscriptionItem) return const quantity = subscriptionItem.quantity if (!quantity) return if (orgUserCount > quantity) { - const billingLeaderOrgUser = await r - .table('OrganizationUser') - .getAll(orgId, {index: 'orgId'}) - .filter({removedAt: null}) - .filter((row: RDatum) => r.expr(['BILLING_LEADER', 'ORG_ADMIN']).contains(row('role'))) - .nth(0) - .run() + const billingLeaderOrgUser = orgUsers.find( + ({role}) => role && ['BILLING_LEADER', 'ORG_ADMIN'].includes(role) + )! const {id: userId} = billingLeaderOrgUser const user = await dataLoader.get('users').loadNonNull(userId) analytics.enterpriseOverUserLimit(user, orgId) diff --git a/packages/server/billing/helpers/teamLimitsCheck.ts b/packages/server/billing/helpers/teamLimitsCheck.ts index 6652c8ea6c7..9d2b8d52944 100644 --- a/packages/server/billing/helpers/teamLimitsCheck.ts +++ b/packages/server/billing/helpers/teamLimitsCheck.ts @@ -21,6 +21,13 @@ import sendTeamsLimitEmail from './sendTeamsLimitEmail' const enableUsageStats = async (userIds: string[], orgId: string) => { const pg = getKysely() + await pg + .updateTable('OrganizationUser') + .set({suggestedTier: 'team'}) + .where('orgId', '=', orgId) + .where('userId', 'in', userIds) + .where('removedAt', 'is', null) + .execute() await r .table('OrganizationUser') .getAll(r.args(userIds), {index: 'userId'}) @@ -87,6 +94,13 @@ export const maybeRemoveRestrictions = async (orgId: string, dataLoader: DataLoa .set({tierLimitExceededAt: null, scheduledLockAt: null, lockedAt: null}) .where('id', '=', orgId) .execute(), + pg + .updateTable('OrganizationUser') + .set({suggestedTier: 'starter'}) + .where('orgId', '=', orgId) + .where('userId', 'in', billingLeadersIds) + .where('removedAt', 'is', null) + .execute(), r .table('OrganizationUser') .getAll(r.args(billingLeadersIds), {index: 'userId'}) diff --git a/packages/server/billing/helpers/updateSubscriptionQuantity.ts b/packages/server/billing/helpers/updateSubscriptionQuantity.ts index 23fcb80095f..b6b2634eb39 100644 --- a/packages/server/billing/helpers/updateSubscriptionQuantity.ts +++ b/packages/server/billing/helpers/updateSubscriptionQuantity.ts @@ -11,9 +11,10 @@ import {getStripeManager} from '../../utils/stripe' */ const updateSubscriptionQuantity = async (orgId: string, logMismatch?: boolean) => { const r = await getRethink() + const pg = getKysely() const manager = getStripeManager() - const org = await getKysely() + const org = await pg .selectFrom('Organization') .selectAll() .where('id', '=', orgId) @@ -34,15 +35,29 @@ const updateSubscriptionQuantity = async (orgId: string, logMismatch?: boolean) return } - const [orgUserCount, teamSubscription] = await Promise.all([ + const [orgUserCountRes, orgUserCount, teamSubscription] = await Promise.all([ + pg + .selectFrom('OrganizationUser') + .select(({fn}) => fn.count('id').as('count')) + .where('orgId', '=', orgId) + .where('removedAt', 'is', null) + .where('inactive', '=', false) + .executeTakeFirstOrThrow(), r .table('OrganizationUser') .getAll(orgId, {index: 'orgId'}) .filter({removedAt: null, inactive: false}) .count() .run(), - await manager.getSubscriptionItem(stripeSubscriptionId) + manager.getSubscriptionItem(stripeSubscriptionId) ]) + if (orgUserCountRes.count !== orgUserCount) { + sendToSentry(new Error('OrganizationUser count mismatch'), { + tags: { + orgId + } + }) + } if ( teamSubscription && teamSubscription.quantity !== undefined && diff --git a/packages/server/dataloader/__tests__/isOrgVerified.test.ts b/packages/server/dataloader/__tests__/isOrgVerified.test.ts index 96a455b9767..93a90d900d0 100644 --- a/packages/server/dataloader/__tests__/isOrgVerified.test.ts +++ b/packages/server/dataloader/__tests__/isOrgVerified.test.ts @@ -35,22 +35,6 @@ const addUsers = async (users: TestUser[]) => { getKysely().insertInto('User').values(users).execute() } -const createTables = async (...tables: string[]) => { - for (const tableName of tables) { - const structure = await r - .db('rethinkdb') - .table('table_config') - .filter({db: config.db, name: tableName}) - .run() - await r.tableCreate(tableName).run() - const {indexes} = structure[0] - for (const index of indexes) { - await r.table(tableName).indexCreate(index).run() - } - await r.table(tableName).indexWait().run() - } -} - type TestOrganizationUser = Partial< Pick & { domain: string @@ -77,13 +61,16 @@ const addOrg = async ( ...member, inactive: member.inactive ?? false, role: member.role ?? null, - removedAt: member.removedAt ?? null + removedAt: member.removedAt ?? null, + tier: 'starter' as const })) const pg = getKysely() - await pg.insertInto('Organization').values(org).execute() - await r.table('OrganizationUser').insert(orgUsers).run() - + await pg + .with('Org', (qc) => qc.insertInto('Organization').values(org)) + .insertInto('OrganizationUser') + .values(orgUsers) + .execute() return orgId } @@ -99,13 +86,11 @@ beforeAll(async () => { await pg.schema.createSchema(TEST_DB).ifNotExists().execute() await r.dbCreate(TEST_DB).run() - await createPGTables('Organization', 'User', 'SAML', 'SAMLDomain') - await createTables('OrganizationUser') + await createPGTables('Organization', 'User', 'SAML', 'SAMLDomain', 'OrganizationUser') }) afterEach(async () => { - await truncatePGTables('Organization', 'User') - await r.table('OrganizationUser').delete().run() + await truncatePGTables('Organization', 'User', 'OrganizationUser') }) afterAll(async () => { diff --git a/packages/server/graphql/mutations/archiveOrganization.ts b/packages/server/graphql/mutations/archiveOrganization.ts index 9c998e49810..cbe6942d08b 100644 --- a/packages/server/graphql/mutations/archiveOrganization.ts +++ b/packages/server/graphql/mutations/archiveOrganization.ts @@ -1,9 +1,11 @@ import {GraphQLID, GraphQLNonNull} from 'graphql' +import {sql} from 'kysely' import {SubscriptionChannel} from 'parabol-client/types/constEnums' import removeTeamsLimitObjects from '../../billing/helpers/removeTeamsLimitObjects' import getRethink from '../../database/rethinkDriver' import Team from '../../database/types/Team' import User from '../../database/types/User' +import getKysely from '../../postgres/getKysely' import IUser from '../../postgres/types/IUser' import safeArchiveTeam from '../../safeMutations/safeArchiveTeam' import {analytics} from '../../utils/analytics/analytics' @@ -81,6 +83,12 @@ export default { const uniqueUserIds = Array.from(new Set(allUserIds)) await Promise.all([ + getKysely() + .updateTable('OrganizationUser') + .set({removedAt: sql`CURRENT_TIMESTAMP`}) + .where('orgId', '=', orgId) + .where('removedAt', 'is', null) + .execute(), r .table('OrganizationUser') .getAll(orgId, {index: 'orgId'}) diff --git a/packages/server/graphql/mutations/helpers/createNewOrg.ts b/packages/server/graphql/mutations/helpers/createNewOrg.ts index bbfeec1f9d1..d4b069caee2 100644 --- a/packages/server/graphql/mutations/helpers/createNewOrg.ts +++ b/packages/server/graphql/mutations/helpers/createNewOrg.ts @@ -30,8 +30,9 @@ export default async function createNewOrg( }) await insertOrgUserAudit([orgId], leaderUserId, 'added') await getKysely() - .insertInto('Organization') - .values({...org, creditCard: null}) + .with('Org', (qc) => qc.insertInto('Organization').values({...org, creditCard: null})) + .insertInto('OrganizationUser') + .values(orgUser) .execute() await r.table('OrganizationUser').insert(orgUser).run() } diff --git a/packages/server/graphql/mutations/helpers/oldUpgradeToTeamTier.ts b/packages/server/graphql/mutations/helpers/oldUpgradeToTeamTier.ts index 24c2ed3f6bc..7e6cfc21ebb 100644 --- a/packages/server/graphql/mutations/helpers/oldUpgradeToTeamTier.ts +++ b/packages/server/graphql/mutations/helpers/oldUpgradeToTeamTier.ts @@ -1,5 +1,4 @@ import removeTeamsLimitObjects from '../../../billing/helpers/removeTeamsLimitObjects' -import getRethink from '../../../database/rethinkDriver' import getKysely from '../../../postgres/getKysely' import {toCreditCard} from '../../../postgres/helpers/toCreditCard' import {fromEpochSeconds} from '../../../utils/epochTime' @@ -15,7 +14,6 @@ const oldUpgradeToTeamTier = async ( email: string, dataLoader: DataLoaderWorker ) => { - const r = await getRethink() const pg = getKysely() const now = new Date() @@ -23,12 +21,9 @@ const oldUpgradeToTeamTier = async ( if (!organization) throw new Error('Bad orgId') const {stripeId, stripeSubscriptionId} = organization - const quantity = await r - .table('OrganizationUser') - .getAll(orgId, {index: 'orgId'}) - .filter({removedAt: null, inactive: false}) - .count() - .run() + const orgUsers = await dataLoader.get('organizationUsersByOrgId').load(orgId) + const activeOrgUsers = orgUsers.filter(({inactive}) => !inactive) + const quantity = activeOrgUsers.length const manager = getStripeManager() const customer = stripeId diff --git a/packages/server/graphql/mutations/helpers/removeFromOrg.ts b/packages/server/graphql/mutations/helpers/removeFromOrg.ts index af706293f0b..dc6f2ad9934 100644 --- a/packages/server/graphql/mutations/helpers/removeFromOrg.ts +++ b/packages/server/graphql/mutations/helpers/removeFromOrg.ts @@ -1,8 +1,9 @@ +import {sql} from 'kysely' import {InvoiceItemType} from 'parabol-client/types/constEnums' import adjustUserCount from '../../../billing/helpers/adjustUserCount' import getRethink from '../../../database/rethinkDriver' -import {RDatum} from '../../../database/stricterR' import OrganizationUser from '../../../database/types/OrganizationUser' +import getKysely from '../../../postgres/getKysely' import getTeamsByOrgIds from '../../../postgres/queries/getTeamsByOrgIds' import {Logger} from '../../../utils/Logger' import setUserTierForUserIds from '../../../utils/setUserTierForUserIds' @@ -17,6 +18,7 @@ const removeFromOrg = async ( dataLoader: DataLoaderWorker ) => { const r = await getRethink() + const pg = getKysely() const now = new Date() const orgTeams = await getTeamsByOrgIds([orgId]) const teamIds = orgTeams.map((team) => team.id) @@ -42,7 +44,15 @@ const removeFromOrg = async ( return arr }, []) - const [organizationUser, user] = await Promise.all([ + const [_pgOrgUser, organizationUser, user] = await Promise.all([ + pg + .updateTable('OrganizationUser') + .set({removedAt: sql`CURRENT_TIMESTAMP`}) + .where('userId', '=', userId) + .where('orgId', '=', orgId) + .where('removedAt', 'is', null) + .returning('role') + .executeTakeFirstOrThrow(), r .table('OrganizationUser') .getAll(userId, {index: 'userId'}) @@ -60,22 +70,19 @@ const removeFromOrg = async ( const organization = await dataLoader.get('organizations').loadNonNull(orgId) // if no other billing leader, promote the oldest // if team tier & no other member, downgrade to starter - const otherBillingLeaders = await r - .table('OrganizationUser') - .getAll(orgId, {index: 'orgId'}) - .filter({removedAt: null}) - .filter((row: RDatum) => r.expr(['BILLING_LEADER', 'ORG_ADMIN']).contains(row('role'))) - .run() + const allOrgUsers = await dataLoader.get('organizationUsersByOrgId').load(orgId) + const otherBillingLeaders = allOrgUsers.filter( + ({role}) => role && ['BILLING_LEADER', 'ORG_ADMIN'].includes(role) + ) if (otherBillingLeaders.length === 0) { - const nextInLine = await r - .table('OrganizationUser') - .getAll(orgId, {index: 'orgId'}) - .filter({removedAt: null}) - .orderBy('joinedAt') - .nth(0) - .default(null) - .run() + const orgUsersByJoinAt = allOrgUsers.sort((a, b) => (a.joinedAt < b.joinedAt ? -1 : 1)) + const nextInLine = orgUsersByJoinAt[0] if (nextInLine) { + await pg + .updateTable('OrganizationUser') + .set({role: 'BILLING_LEADER'}) + .where('id', '=', nextInLine.id) + .execute() await r .table('OrganizationUser') .get(nextInLine.id) diff --git a/packages/server/graphql/mutations/moveTeamToOrg.ts b/packages/server/graphql/mutations/moveTeamToOrg.ts index 7e3ea1ad40d..972a7a81043 100644 --- a/packages/server/graphql/mutations/moveTeamToOrg.ts +++ b/packages/server/graphql/mutations/moveTeamToOrg.ts @@ -3,7 +3,6 @@ import {InvoiceItemType} from 'parabol-client/types/constEnums' import adjustUserCount from '../../billing/helpers/adjustUserCount' import getRethink from '../../database/rethinkDriver' import {RDatum} from '../../database/stricterR' -import Notification from '../../database/types/Notification' import getKysely from '../../postgres/getKysely' import getTeamsByIds from '../../postgres/queries/getTeamsByIds' import updateMeetingTemplateOrgId from '../../postgres/queries/updateMeetingTemplateOrgId' @@ -50,13 +49,11 @@ const moveToOrg = async ( if (!userId) { return standardError(new Error('No userId provided')) } - const newOrganizationUser = await r - .table('OrganizationUser') - .getAll(userId, {index: 'userId'}) - .filter({orgId, removedAt: null}) - .nth(0) - .default(null) - .run() + const [newOrganizationUser, oldOrganizationUser] = await Promise.all([ + dataLoader.get('organizationUsersByUserIdOrgId').load({orgId, userId}), + dataLoader.get('organizationUsersByUserIdOrgId').load({orgId: currentOrgId, userId}) + ]) + if (!newOrganizationUser) { return standardError(new Error('Not on organization'), {userId}) } @@ -65,14 +62,9 @@ const moveToOrg = async ( if (!isBillingLeaderForOrg) { return standardError(new Error('Not organization leader'), {userId}) } - const oldOrganizationUser = await r - .table('OrganizationUser') - .getAll(userId, {index: 'userId'}) - .filter({orgId: currentOrgId, removedAt: null}) - .nth(0) - .run() + const isBillingLeaderForTeam = - oldOrganizationUser.role === 'BILLING_LEADER' || oldOrganizationUser.role === 'ORG_ADMIN' + oldOrganizationUser?.role === 'BILLING_LEADER' || oldOrganizationUser?.role === 'ORG_ADMIN' if (!isBillingLeaderForTeam) { return standardError(new Error('Not organization leader'), {userId}) } @@ -90,31 +82,25 @@ const moveToOrg = async ( trialStartDate: org.trialStartDate, updatedAt: new Date() } - const [rethinkResult] = await Promise.all([ - r({ - notifications: r - .table('Notification') - .filter({teamId}) - .filter((notification: RDatum) => notification('orgId').default(null).ne(null)) - .update({orgId}) as unknown as Notification[], - newToOrgUserIds: r - .table('TeamMember') - .getAll(teamId, {index: 'teamId'}) - .filter({isNotRemoved: true}) - .filter((teamMember: RDatum) => { - return r - .table('OrganizationUser') - .getAll(teamMember('userId'), {index: 'userId'}) - .filter({orgId, removedAt: null}) - .count() - .eq(0) - })('userId') - .coerceTo('array') as unknown as string[] - }).run(), + const teamMembers = await dataLoader.get('teamMembersByTeamId').load(teamId) + const teamMemberUserIds = teamMembers.map(({userId}) => userId) + const orgUserKeys = teamMemberUserIds.map((userId) => ({userId, orgId})) + const existingOrgUsers = ( + await dataLoader.get('organizationUsersByUserIdOrgId').loadMany(orgUserKeys) + ).filter(isValid) + const newToOrgUserIds = teamMemberUserIds.filter( + (userId) => !existingOrgUsers.find((orgUser) => orgUser.userId === userId) + ) + await Promise.all([ + r + .table('Notification') + .filter({teamId}) + .filter((notification: RDatum) => notification('orgId').default(null).ne(null)) + .update({orgId}) + .run(), updateMeetingTemplateOrgId(currentOrgId, orgId), updateTeamByTeamId(updates, teamId) ]) - const {newToOrgUserIds} = rethinkResult // if no teams remain on the org, remove it await safeArchiveEmptyStarterOrganization(currentOrgId, dataLoader) diff --git a/packages/server/graphql/mutations/oldUpgradeToTeamTier.ts b/packages/server/graphql/mutations/oldUpgradeToTeamTier.ts index ddcd8d27035..7bf2e98adec 100644 --- a/packages/server/graphql/mutations/oldUpgradeToTeamTier.ts +++ b/packages/server/graphql/mutations/oldUpgradeToTeamTier.ts @@ -1,6 +1,7 @@ import {GraphQLID, GraphQLNonNull} from 'graphql' import {SubscriptionChannel} from 'parabol-client/types/constEnums' import getRethink from '../../database/rethinkDriver' +import getKysely from '../../postgres/getKysely' import {analytics} from '../../utils/analytics/analytics' import {getUserId} from '../../utils/authorization' import publish from '../../utils/publish' @@ -65,6 +66,13 @@ export default { const activeMeetings = await hideConversionModal(orgId, dataLoader) const meetingIds = activeMeetings.map(({id}) => id) + await getKysely() + .updateTable('OrganizationUser') + .set({role: 'BILLING_LEADER'}) + .where('userId', '=', viewerId) + .where('orgId', '=', orgId) + .where('removedAt', 'is', null) + .execute() await r .table('OrganizationUser') .getAll(viewerId, {index: 'userId'}) From 473c0c5b22596215b8866e213f25dd5dc5e83469 Mon Sep 17 00:00:00 2001 From: Matt Krick Date: Wed, 10 Jul 2024 09:11:52 -0700 Subject: [PATCH 36/47] remove newUserUntil on client Signed-off-by: Matt Krick --- .../client/components/BillingLeaderActionMenu.tsx | 15 +++------------ .../components/OrgUserRow/OrgMemberRow.tsx | 9 +-------- .../graphql/private/mutations/autopauseUsers.ts | 14 +++++++++++++- .../server/graphql/private/queries/suOrgCount.ts | 15 +++++++++++++++ 4 files changed, 32 insertions(+), 21 deletions(-) diff --git a/packages/client/components/BillingLeaderActionMenu.tsx b/packages/client/components/BillingLeaderActionMenu.tsx index 9f6e1e7604c..79811629b26 100644 --- a/packages/client/components/BillingLeaderActionMenu.tsx +++ b/packages/client/components/BillingLeaderActionMenu.tsx @@ -36,7 +36,6 @@ const BillingLeaderActionMenu = (props: Props) => { graphql` fragment BillingLeaderActionMenu_organization on Organization { id - billingTier } `, organizationRef @@ -45,7 +44,6 @@ const BillingLeaderActionMenu = (props: Props) => { graphql` fragment BillingLeaderActionMenu_organizationUser on OrganizationUser { role - newUserUntil user { id } @@ -54,9 +52,9 @@ const BillingLeaderActionMenu = (props: Props) => { organizationUserRef ) const atmosphere = useAtmosphere() - const {id: orgId, billingTier} = organization + const {id: orgId} = organization const {viewerId} = atmosphere - const {newUserUntil, role, user} = organizationUser + const {role, user} = organizationUser const isBillingLeader = role === 'BILLING_LEADER' const {id: userId} = user @@ -82,14 +80,7 @@ const BillingLeaderActionMenu = (props: Props) => { )} {viewerId !== userId && ( - new Date() - ? 'Refund and Remove' - : 'Remove from Organization' - } - onClick={toggleRemove} - /> + )} diff --git a/packages/client/modules/userDashboard/components/OrgUserRow/OrgMemberRow.tsx b/packages/client/modules/userDashboard/components/OrgUserRow/OrgMemberRow.tsx index 7087976fa84..d6b0862a091 100644 --- a/packages/client/modules/userDashboard/components/OrgUserRow/OrgMemberRow.tsx +++ b/packages/client/modules/userDashboard/components/OrgUserRow/OrgMemberRow.tsx @@ -21,7 +21,6 @@ import RowInfoHeader from '../../../../components/Row/RowInfoHeader' import RowInfoHeading from '../../../../components/Row/RowInfoHeading' import RowInfoLink from '../../../../components/Row/RowInfoLink' import BaseTag from '../../../../components/Tag/BaseTag' -import EmphasisTag from '../../../../components/Tag/EmphasisTag' import InactiveTag from '../../../../components/Tag/InactiveTag' import RoleTag from '../../../../components/Tag/RoleTag' import {MenuPosition} from '../../../../hooks/useCoords' @@ -126,7 +125,6 @@ interface UserInfoProps { isBillingLeader: boolean isOrgAdmin: boolean inactive: boolean | null - newUserUntil: string } const UserInfo: React.FC = ({ @@ -134,8 +132,7 @@ const UserInfo: React.FC = ({ email, isBillingLeader, isOrgAdmin, - inactive, - newUserUntil + inactive }) => ( @@ -143,7 +140,6 @@ const UserInfo: React.FC = ({ {isBillingLeader && Billing Leader} {isOrgAdmin && Org Admin} {inactive && !isBillingLeader && !isOrgAdmin && Inactive} - {new Date(newUserUntil) > new Date() && New} {email} @@ -258,7 +254,6 @@ const OrgMemberRow = (props: Props) => { preferredName } role - newUserUntil ...BillingLeaderActionMenu_organizationUser ...OrgAdminActionMenu_organizationUser } @@ -269,7 +264,6 @@ const OrgMemberRow = (props: Props) => { const {isViewerBillingLeader, isViewerOrgAdmin} = organization const { - newUserUntil, user: {email, inactive, picture, preferredName}, role } = organizationUser @@ -291,7 +285,6 @@ const OrgMemberRow = (props: Props) => { isBillingLeader={isBillingLeader} isOrgAdmin={isOrgAdmin} inactive={inactive} - newUserUntil={newUserUntil} /> { const r = await getRethink() - + const pg = getKysely() // RESOLUTION const activeThresh = new Date(Date.now() - Threshold.AUTO_PAUSE) const userIdsToPause = await getUserIdsToPause(activeThresh) @@ -21,6 +22,17 @@ const autopauseUsers: MutationResolvers['autopauseUsers'] = async ( const skip = i * BATCH_SIZE const userIdBatch = userIdsToPause.slice(skip, skip + BATCH_SIZE) if (userIdBatch.length < 1) break + const pgResults = await pg + .selectFrom('OrganizationUser') + .select(({fn}) => ['userId', fn.agg('array_agg', ['orgId']).as('orgIds')]) + .where('userId', 'in', userIdBatch) + .where('removedAt', 'is', null) + .groupBy('userId') + .execute() + + // TEST in Phase 2! + console.log(pgResults) + const results = (await ( r .table('OrganizationUser') diff --git a/packages/server/graphql/private/queries/suOrgCount.ts b/packages/server/graphql/private/queries/suOrgCount.ts index f23c48c6850..ab86ce2fcd2 100644 --- a/packages/server/graphql/private/queries/suOrgCount.ts +++ b/packages/server/graphql/private/queries/suOrgCount.ts @@ -1,8 +1,23 @@ import getRethink from '../../../database/rethinkDriver' import {RValue} from '../../../database/stricterR' +import getKysely from '../../../postgres/getKysely' import {QueryResolvers} from '../resolverTypes' const suOrgCount: QueryResolvers['suOrgCount'] = async (_source, {minOrgSize, tier}) => { + const pg = getKysely() + const pgResults = await pg + .selectFrom('OrganizationUser') + .select(({fn}) => fn.count('id').as('orgSize')) + .where('tier', '=', tier) + .where('inactive', '=', false) + .where('removedAt', 'is', null) + .groupBy('orgId') + .having(({eb, fn}) => eb(fn.count('id'), '>=', minOrgSize)) + .execute() + + // TEST in Phase 2! + console.log(pgResults) + const r = await getRethink() return ( r From 49be1bc0e081c94338ae082c742f0c15aa3d5018 Mon Sep 17 00:00:00 2001 From: Matt Krick Date: Wed, 10 Jul 2024 11:53:12 -0700 Subject: [PATCH 37/47] fix: update all instances of OrganizationUser Signed-off-by: Matt Krick --- .../server/billing/helpers/adjustUserCount.ts | 4 +- .../private/mutations/backupOrganization.ts | 3 -- .../private/mutations/connectSocket.ts | 18 +++---- .../mutations/draftEnterpriseInvoice.ts | 8 +++ .../private/mutations/stripeFailPayment.ts | 15 ++---- .../private/mutations/toggleAllowInsights.ts | 10 ++++ .../private/mutations/upgradeToTeamTier.ts | 7 +++ .../private/queries/suCountTiersForUser.ts | 8 ++- .../graphql/private/queries/suProOrgInfo.ts | 14 +++++- .../mutations/acceptRequestToJoinDomain.ts | 8 +-- .../mutations/createStripeSubscription.ts | 16 ++---- .../public/mutations/setOrgUserRole.ts | 49 +++++++++---------- .../subscriptions/organizationSubscription.ts | 11 ++--- .../queries/helpers/countTiersForUserId.ts | 13 ++--- packages/server/graphql/queries/invoices.ts | 14 ++---- .../safeArchiveEmptyStarterOrganization.ts | 10 +++- .../isRequestToJoinDomainAllowed.test.ts | 41 +++++++--------- packages/server/utils/authorization.ts | 26 +--------- .../server/utils/getActiveDomainForOrgId.ts | 15 ++---- packages/server/utils/setTierForOrgUsers.ts | 8 ++- packages/server/utils/setUserTierForOrgId.ts | 9 ++++ .../server/utils/setUserTierForUserIds.ts | 8 ++- scripts/toolboxSrc/setIsEnterprise.ts | 44 +++-------------- 23 files changed, 162 insertions(+), 197 deletions(-) diff --git a/packages/server/billing/helpers/adjustUserCount.ts b/packages/server/billing/helpers/adjustUserCount.ts index 4cb49b859d7..360a72df49d 100644 --- a/packages/server/billing/helpers/adjustUserCount.ts +++ b/packages/server/billing/helpers/adjustUserCount.ts @@ -37,7 +37,7 @@ const maybeUpdateOrganizationActiveDomain = async ( return // don't modify if we can't guess the domain or the domain we guess is the current domain - const domain = await getActiveDomainForOrgId(orgId) + const domain = await getActiveDomainForOrgId(orgId, dataLoader) if (!domain || domain === activeDomain) return organization.activeDomain = domain const pg = getKysely() @@ -82,6 +82,7 @@ const addUser = async (orgIds: string[], user: IUser, dataLoader: DataLoaderWork dataLoader.get('organizations').loadMany(orgIds), dataLoader.get('organizationUsersByUserId').load(userId) ]) + dataLoader.get('organizationUsersByUserId').clear(userId) const organizations = rawOrganizations.filter(isValid) const docs = orgIds.map((orgId) => { const oldOrganizationUser = organizationUsers.find( @@ -159,7 +160,6 @@ export default async function adjustUserCount( const dbAction = dbActionTypeLookup[type] await dbAction(orgIds, user, dataLoader) - const auditEventType = auditEventTypeLookup[type] await insertOrgUserAudit(orgIds, userId, auditEventType) diff --git a/packages/server/graphql/private/mutations/backupOrganization.ts b/packages/server/graphql/private/mutations/backupOrganization.ts index c347258c54b..d05c2e55189 100644 --- a/packages/server/graphql/private/mutations/backupOrganization.ts +++ b/packages/server/graphql/private/mutations/backupOrganization.ts @@ -191,9 +191,6 @@ const backupOrganization: MutationResolvers['backupOrganization'] = async (_sour newMeeting: (r.table('NewMeeting').getAll(r.args(teamIds), {index: 'teamId'}) as any) .coerceTo('array') .do((items: RValue) => r.db(DESTINATION).table('NewMeeting').insert(items)), - organizationUser: (r.table('OrganizationUser').getAll(r.args(orgIds), {index: 'orgId'}) as any) - .coerceTo('array') - .do((items: RValue) => r.db(DESTINATION).table('OrganizationUser').insert(items)), reflectPrompt: (r.table('ReflectPrompt').getAll(r.args(teamIds), {index: 'teamId'}) as any) .coerceTo('array') .do((items: RValue) => r.db(DESTINATION).table('ReflectPrompt').insert(items)), diff --git a/packages/server/graphql/private/mutations/connectSocket.ts b/packages/server/graphql/private/mutations/connectSocket.ts index 002b6d67001..6b69ebcb515 100644 --- a/packages/server/graphql/private/mutations/connectSocket.ts +++ b/packages/server/graphql/private/mutations/connectSocket.ts @@ -1,6 +1,5 @@ import {InvoiceItemType, SubscriptionChannel} from 'parabol-client/types/constEnums' import adjustUserCount from '../../../billing/helpers/adjustUserCount' -import getRethink from '../../../database/rethinkDriver' import updateUser from '../../../postgres/queries/updateUser' import {Logger} from '../../../utils/Logger' import {analytics} from '../../../utils/analytics/analytics' @@ -8,6 +7,7 @@ import {getUserId} from '../../../utils/authorization' import getListeningUserIds, {RedisCommand} from '../../../utils/getListeningUserIds' import getRedis from '../../../utils/getRedis' import publish from '../../../utils/publish' +import {DataLoaderWorker} from '../../graphql' import {MutationResolvers} from '../resolverTypes' export interface UserPresence { @@ -16,12 +16,18 @@ export interface UserPresence { socketId: string } +const handleInactive = async (userId: string, dataLoader: DataLoaderWorker) => { + const orgUsers = await dataLoader.get('organizationUsersByUserId').load(userId) + const orgIds = orgUsers.map(({orgId}) => orgId) + await adjustUserCount(userId, orgIds, InvoiceItemType.UNPAUSE_USER, dataLoader).catch(Logger.log) + // TODO: re-identify +} + const connectSocket: MutationResolvers['connectSocket'] = async ( _source, {socketInstanceId}, {authToken, dataLoader, socketId} ) => { - const r = await getRethink() const redis = getRedis() const now = new Date() @@ -40,13 +46,7 @@ const connectSocket: MutationResolvers['connectSocket'] = async ( // no need to wait for this, it's just for billing if (inactive) { - const orgIds = await r - .table('OrganizationUser') - .getAll(userId, {index: 'userId'}) - .filter({removedAt: null, inactive: true})('orgId') - .run() - adjustUserCount(userId, orgIds, InvoiceItemType.UNPAUSE_USER, dataLoader).catch(Logger.log) - // TODO: re-identify + handleInactive(userId, dataLoader) } const datesAreOnSameDay = now.toDateString() === lastSeenAt.toDateString() if (!datesAreOnSameDay) { diff --git a/packages/server/graphql/private/mutations/draftEnterpriseInvoice.ts b/packages/server/graphql/private/mutations/draftEnterpriseInvoice.ts index a3f1dfe13f6..e07c5fadcec 100644 --- a/packages/server/graphql/private/mutations/draftEnterpriseInvoice.ts +++ b/packages/server/graphql/private/mutations/draftEnterpriseInvoice.ts @@ -19,6 +19,7 @@ const getBillingLeaderUser = async ( dataLoader: DataLoaderWorker ) => { const r = await getRethink() + const pg = getKysely() if (email) { const user = await getUserByEmail(email) if (!user) { @@ -32,6 +33,13 @@ const getBillingLeaderUser = async ( throw new Error('Email not associated with a user on that org') } if (organizationUser.role !== 'ORG_ADMIN') { + await pg + .updateTable('OrganizationUser') + .set({role: 'BILLING_LEADER'}) + .where('userId', '=', userId) + .where('orgId', '=', orgId) + .where('removedAt', 'is', null) + .execute() await r .table('OrganizationUser') .getAll(userId, {index: 'userId'}) diff --git a/packages/server/graphql/private/mutations/stripeFailPayment.ts b/packages/server/graphql/private/mutations/stripeFailPayment.ts index 64495a4b3b9..023a6037369 100644 --- a/packages/server/graphql/private/mutations/stripeFailPayment.ts +++ b/packages/server/graphql/private/mutations/stripeFailPayment.ts @@ -2,7 +2,6 @@ import {SubscriptionChannel} from 'parabol-client/types/constEnums' import Stripe from 'stripe' import terminateSubscription from '../../../billing/helpers/terminateSubscription' import getRethink from '../../../database/rethinkDriver' -import {RDatum} from '../../../database/stricterR' import NotificationPaymentRejected from '../../../database/types/NotificationPaymentRejected' import {isSuperUser} from '../../../utils/authorization' import publish from '../../../utils/publish' @@ -76,15 +75,11 @@ const stripeFailPayment: MutationResolvers['stripeFailPayment'] = async ( // Not to handle this particular case in 23 hours, we do it now await terminateSubscription(orgId) } - - const billingLeaderUserIds = (await r - .table('OrganizationUser') - .getAll(orgId, {index: 'orgId'}) - .filter({removedAt: null}) - .filter((row: RDatum) => r.expr(['BILLING_LEADER', 'ORG_ADMIN']).contains(row('role')))( - 'userId' - ) - .run()) as string[] + const orgUsers = await dataLoader.get('organizationUsersByOrgId').load(orgId) + const billingLeaderOrgUsers = orgUsers.filter( + ({role}) => role && ['BILLING_LEADER', 'ORG_ADMIN'].includes(role) + ) + const billingLeaderUserIds = billingLeaderOrgUsers.map(({userId}) => userId) const {default_source} = customer diff --git a/packages/server/graphql/private/mutations/toggleAllowInsights.ts b/packages/server/graphql/private/mutations/toggleAllowInsights.ts index e10494235df..0d4a6d80cb4 100644 --- a/packages/server/graphql/private/mutations/toggleAllowInsights.ts +++ b/packages/server/graphql/private/mutations/toggleAllowInsights.ts @@ -1,4 +1,5 @@ import {r, RValue} from 'rethinkdb-ts' +import getKysely from '../../../postgres/getKysely' import {getUsersByEmails} from '../../../postgres/queries/getUsersByEmails' import {MutationResolvers} from '../resolverTypes' @@ -7,6 +8,7 @@ const toggleAllowInsights: MutationResolvers['toggleAllowInsights'] = async ( {suggestedTier, domain, emails}, {dataLoader} ) => { + const pg = getKysely() const organizations = await dataLoader.get('organizationsByActiveDomain').load(domain) if (organizations.length === 0) { return { @@ -24,6 +26,14 @@ const toggleAllowInsights: MutationResolvers['toggleAllowInsights'] = async ( const userIds = users.map(({id}) => id) const recordsReplaced = await Promise.all( userIds.map(async (userId) => { + await pg + .updateTable('OrganizationUser') + .set({suggestedTier}) + .where('userId', '=', userId) + .where('orgId', 'in', orgIds) + .where('removedAt', 'is', null) + .returning('id') + .execute() return r .table('OrganizationUser') .getAll(r.args(orgIds), {index: 'orgId'}) diff --git a/packages/server/graphql/private/mutations/upgradeToTeamTier.ts b/packages/server/graphql/private/mutations/upgradeToTeamTier.ts index e0e04d94b5a..e051a0ccb81 100644 --- a/packages/server/graphql/private/mutations/upgradeToTeamTier.ts +++ b/packages/server/graphql/private/mutations/upgradeToTeamTier.ts @@ -100,6 +100,13 @@ const upgradeToTeamTier: MutationResolvers['upgradeToTeamTier'] = async ( const activeMeetings = await hideConversionModal(orgId, dataLoader) const meetingIds = activeMeetings.map(({id}) => id) + await pg + .updateTable('OrganizationUser') + .set({role: 'BILLING_LEADER'}) + .where('userId', '=', viewerId) + .where('orgId', '=', orgId) + .where('removedAt', 'is', null) + .execute() await r .table('OrganizationUser') .getAll(viewerId, {index: 'userId'}) diff --git a/packages/server/graphql/private/queries/suCountTiersForUser.ts b/packages/server/graphql/private/queries/suCountTiersForUser.ts index 9040fda22a8..5796c09ffff 100644 --- a/packages/server/graphql/private/queries/suCountTiersForUser.ts +++ b/packages/server/graphql/private/queries/suCountTiersForUser.ts @@ -1,9 +1,13 @@ import countTiersForUserId from '../../queries/helpers/countTiersForUserId' import {QueryResolvers} from '../resolverTypes' -const suCountTiersForUser: QueryResolvers['suCountTiersForUser'] = async (_source, {userId}) => { +const suCountTiersForUser: QueryResolvers['suCountTiersForUser'] = async ( + _source, + {userId}, + {dataLoader} +) => { return { - ...(await countTiersForUserId(userId)), + ...(await countTiersForUserId(userId, dataLoader)), userId } } diff --git a/packages/server/graphql/private/queries/suProOrgInfo.ts b/packages/server/graphql/private/queries/suProOrgInfo.ts index 870c5237419..51a444b97e5 100644 --- a/packages/server/graphql/private/queries/suProOrgInfo.ts +++ b/packages/server/graphql/private/queries/suProOrgInfo.ts @@ -1,14 +1,26 @@ import getRethink from '../../../database/rethinkDriver' import {RDatum} from '../../../database/stricterR' import {selectOrganizations} from '../../../dataloader/primaryKeyLoaderMakers' +import getKysely from '../../../postgres/getKysely' import {QueryResolvers} from '../resolverTypes' const suProOrgInfo: QueryResolvers['suProOrgInfo'] = async (_source, {includeInactive}) => { const r = await getRethink() + const pg = getKysely() const proOrgs = await selectOrganizations().where('tier', '=', 'team').execute() if (includeInactive) return proOrgs const proOrgIds = proOrgs.map(({id}) => id) + const pgResults = await pg + .selectFrom('OrganizationUser') + .select(({fn}) => fn.count('id').as('orgSize')) + .where('orgId', 'in', proOrgIds) + .where('inactive', '=', false) + .where('removedAt', 'is', null) + .groupBy('orgId') + .having(({eb, fn}) => eb(fn.count('id'), '>=', 1)) + .execute() + const activeOrgIds = await ( r .table('OrganizationUser') @@ -20,7 +32,7 @@ const suProOrgInfo: QueryResolvers['suProOrgInfo'] = async (_source, {includeIna .ungroup() .filter((row: RDatum) => row('reduction').ge(1))('group') .run() - + console.log({pgResults, activeOrgIds}) return proOrgs.filter((org) => activeOrgIds.includes(org.id)) } diff --git a/packages/server/graphql/public/mutations/acceptRequestToJoinDomain.ts b/packages/server/graphql/public/mutations/acceptRequestToJoinDomain.ts index 9c7cddc6117..29014431ff3 100644 --- a/packages/server/graphql/public/mutations/acceptRequestToJoinDomain.ts +++ b/packages/server/graphql/public/mutations/acceptRequestToJoinDomain.ts @@ -91,13 +91,7 @@ const acceptRequestToJoinDomain: MutationResolvers['acceptRequestToJoinDomain'] for (const validTeam of validTeams) { const {id: teamId, orgId} = validTeam const [organizationUser] = await Promise.all([ - r - .table('OrganizationUser') - .getAll(userId, {index: 'userId'}) - .filter({orgId, removedAt: null}) - .nth(0) - .default(null) - .run(), + dataLoader.get('organizationUsersByUserIdOrgId').load({orgId, userId}), insertNewTeamMember(user, teamId), addTeamIdToTMS(userId, teamId) ]) diff --git a/packages/server/graphql/public/mutations/createStripeSubscription.ts b/packages/server/graphql/public/mutations/createStripeSubscription.ts index 47fb36d4dba..6b65428c4b2 100644 --- a/packages/server/graphql/public/mutations/createStripeSubscription.ts +++ b/packages/server/graphql/public/mutations/createStripeSubscription.ts @@ -1,5 +1,4 @@ import Stripe from 'stripe' -import getRethink from '../../../database/rethinkDriver' import {getUserId} from '../../../utils/authorization' import standardError from '../../../utils/standardError' import {getStripeManager} from '../../../utils/stripe' @@ -11,20 +10,15 @@ const createStripeSubscription: MutationResolvers['createStripeSubscription'] = {authToken, dataLoader} ) => { const viewerId = getUserId(authToken) - const r = await getRethink() - const [viewer, organization, orgUsersCount, organizationUser] = await Promise.all([ + const [viewer, organization, orgUsers] = await Promise.all([ dataLoader.get('users').loadNonNull(viewerId), dataLoader.get('organizations').loadNonNull(orgId), - r - .table('OrganizationUser') - .getAll(orgId, {index: 'orgId'}) - .filter({removedAt: null, inactive: false}) - .count() - .run(), - dataLoader.get('organizationUsersByUserIdOrgId').load({orgId, userId: viewerId}) + dataLoader.get('organizationUsersByOrgId').load(orgId) ]) - + const activeOrgUsers = orgUsers.filter(({inactive}) => !inactive) + const orgUsersCount = activeOrgUsers.length + const organizationUser = orgUsers.find(({userId}) => userId === viewerId) if (!organizationUser) return standardError(new Error('Unable to create subscription'), { userId: viewerId diff --git a/packages/server/graphql/public/mutations/setOrgUserRole.ts b/packages/server/graphql/public/mutations/setOrgUserRole.ts index 5b6d2bb4fc6..7be2a4f0b32 100644 --- a/packages/server/graphql/public/mutations/setOrgUserRole.ts +++ b/packages/server/graphql/public/mutations/setOrgUserRole.ts @@ -1,7 +1,7 @@ import {SubscriptionChannel} from 'parabol-client/types/constEnums' import getRethink from '../../../database/rethinkDriver' -import {RDatum} from '../../../database/stricterR' import NotificationPromoteToBillingLeader from '../../../database/types/NotificationPromoteToBillingLeader' +import getKysely from '../../../postgres/getKysely' import {analytics} from '../../../utils/analytics/analytics' import {getUserId, isSuperUser, isUserBillingLeader} from '../../../utils/authorization' import publish from '../../../utils/publish' @@ -22,6 +22,7 @@ const setOrgUserRole: MutationResolvers['setOrgUserRole'] = async ( {authToken, dataLoader, socketId: mutatorId} ) => { const r = await getRethink() + const pg = getKysely() const operationId = dataLoader.share() const subOptions = {mutatorId, operationId} @@ -39,24 +40,12 @@ const setOrgUserRole: MutationResolvers['setOrgUserRole'] = async ( return standardError(new Error('Invalid role to set'), {userId: viewerId}) } - const [organizationUser, viewer, viewerOrgUser] = await Promise.all([ - r - .table('OrganizationUser') - .getAll(userId, {index: 'userId'}) - .filter({orgId, removedAt: null}) - .nth(0) - .default(null) - .run(), - dataLoader.get('users').loadNonNull(viewerId), - r - .table('OrganizationUser') - .getAll(viewerId, {index: 'userId'}) - .filter({orgId, removedAt: null}) - .nth(0) - .default(null) - .run() + const [orgUsers, viewer] = await Promise.all([ + dataLoader.get('organizationUsersByOrgId').load(orgId), + dataLoader.get('users').loadNonNull(viewerId) ]) - + const organizationUser = orgUsers.find((orgUser) => orgUser.userId === userId) + const viewerOrgUser = orgUsers.find((orgUser) => orgUser.userId === viewerId) if (!organizationUser) { return standardError(new Error('Cannot find org user'), { userId: viewerId @@ -76,13 +65,10 @@ const setOrgUserRole: MutationResolvers['setOrgUserRole'] = async ( // if someone is leaving, make sure there is someone else to take their place if (userId === viewerId) { - const leaderCount = await r - .table('OrganizationUser') - .getAll(orgId, {index: 'orgId'}) - .filter({removedAt: null}) - .filter((row: RDatum) => r.expr(['BILLING_LEADER', 'ORG_ADMIN']).contains(row('role'))) - .count() - .run() + const leaders = orgUsers.filter( + ({role}) => role && ['BILLING_LEADER', 'ORG_ADMIN'].includes(role) + ) + const leaderCount = leaders.length if (leaderCount === 1 && !roleToSet) { return standardError(new Error('You’re the last leader, you can’t give that up'), { userId: viewerId @@ -99,8 +85,17 @@ const setOrgUserRole: MutationResolvers['setOrgUserRole'] = async ( notificationIdsAdded: [] } } - await r.table('OrganizationUser').get(organizationUserId).update({role: roleToSet}).run() - + await pg + .updateTable('OrganizationUser') + .set({role: roleToSet || null}) + .where('id', '=', organizationUserId) + .execute() + await r + .table('OrganizationUser') + .get(organizationUserId) + .update({role: roleToSet || null}) + .run() + organizationUser.role = roleToSet || null if (roleToSet !== 'ORG_ADMIN') { const modificationType = roleToSet === 'BILLING_LEADER' ? 'add' : 'remove' analytics.billingLeaderModified(viewer, userId, orgId, modificationType) diff --git a/packages/server/graphql/public/subscriptions/organizationSubscription.ts b/packages/server/graphql/public/subscriptions/organizationSubscription.ts index 03bd9a377fd..aaaf5e37a87 100644 --- a/packages/server/graphql/public/subscriptions/organizationSubscription.ts +++ b/packages/server/graphql/public/subscriptions/organizationSubscription.ts @@ -1,19 +1,14 @@ import {SubscriptionChannel} from 'parabol-client/types/constEnums' -import getRethink from '../../../database/rethinkDriver' import {getUserId} from '../../../utils/authorization' import getPubSub from '../../../utils/getPubSub' import {SubscriptionResolvers} from '../resolverTypes' const organizationSubscription: SubscriptionResolvers['organizationSubscription'] = { - subscribe: async (_source, _args, {authToken}) => { + subscribe: async (_source, _args, {authToken, dataLoader}) => { // AUTH const viewerId = getUserId(authToken) - const r = await getRethink() - const organizationUsers = await r - .table('OrganizationUser') - .getAll(viewerId, {index: 'userId'}) - .filter({removedAt: null}) - .run() + + const organizationUsers = await dataLoader.get('organizationUsersByUserId').load(viewerId) const orgIds = organizationUsers.map(({orgId}) => orgId) // RESOLUTION diff --git a/packages/server/graphql/queries/helpers/countTiersForUserId.ts b/packages/server/graphql/queries/helpers/countTiersForUserId.ts index 4e5aa7eee19..9a16bcd3da0 100644 --- a/packages/server/graphql/queries/helpers/countTiersForUserId.ts +++ b/packages/server/graphql/queries/helpers/countTiersForUserId.ts @@ -1,16 +1,11 @@ -import getRethink from '../../../database/rethinkDriver' -import OrganizationUser from '../../../database/types/OrganizationUser' +import {DataLoaderInstance} from '../../../dataloader/RootDataLoader' // breaking this out into its own helper so it can be used directly to // populate segment traits -const countTiersForUserId = async (userId: string) => { - const r = await getRethink() - const organizationUsers = (await r - .table('OrganizationUser') - .getAll(userId, {index: 'userId'}) - .filter({inactive: false, removedAt: null}) - .run()) as OrganizationUser[] +const countTiersForUserId = async (userId: string, dataLoader: DataLoaderInstance) => { + const allOrgUsers = await dataLoader.get('organizationUsersByUserId').load(userId) + const organizationUsers = allOrgUsers.filter(({inactive}) => !inactive) const tierStarterCount = organizationUsers.filter( (organizationUser) => organizationUser.tier === 'starter' ).length diff --git a/packages/server/graphql/queries/invoices.ts b/packages/server/graphql/queries/invoices.ts index 82cab7a8849..23e6b7c029d 100644 --- a/packages/server/graphql/queries/invoices.ts +++ b/packages/server/graphql/queries/invoices.ts @@ -40,7 +40,7 @@ export default { // RESOLUTION const {stripeId} = await dataLoader.get('organizations').loadNonNull(orgId) const dbAfter = after ? new Date(after) : r.maxval - const [tooManyInvoices, orgUserCount] = await Promise.all([ + const [tooManyInvoices, orgUsers] = await Promise.all([ r .table('Invoice') .between([orgId, r.minval], [orgId, dbAfter], { @@ -54,16 +54,10 @@ export default { .orderBy(r.desc('startAt'), r.desc('createdAt')) .limit(first + 1) .run(), - r - .table('OrganizationUser') - .getAll(orgId, {index: 'orgId'}) - .filter({ - inactive: false, - removedAt: null - }) - .count() - .run() + dataLoader.get('organizationUsersByOrgId').load(orgId) ]) + const activeOrgUsers = orgUsers.filter(({inactive}) => !inactive) + const orgUserCount = activeOrgUsers.length const org = await dataLoader.get('organizations').loadNonNull(orgId) const upcomingInvoice = after ? undefined diff --git a/packages/server/safeMutations/safeArchiveEmptyStarterOrganization.ts b/packages/server/safeMutations/safeArchiveEmptyStarterOrganization.ts index 86205cdf3f8..c5ddd92aec5 100644 --- a/packages/server/safeMutations/safeArchiveEmptyStarterOrganization.ts +++ b/packages/server/safeMutations/safeArchiveEmptyStarterOrganization.ts @@ -1,5 +1,7 @@ +import {sql} from 'kysely' import getRethink from '../database/rethinkDriver' import {DataLoaderInstance} from '../dataloader/RootDataLoader' +import getKysely from '../postgres/getKysely' import getTeamsByOrgIds from '../postgres/queries/getTeamsByOrgIds' // Only does something if the organization is empty & not paid @@ -10,6 +12,7 @@ const safeArchiveEmptyStarterOrganization = async ( dataLoader: DataLoaderInstance ) => { const r = await getRethink() + const pg = getKysely() const now = new Date() const orgTeams = await getTeamsByOrgIds([orgId]) const teamCountRemainingOnOldOrg = orgTeams.length @@ -17,7 +20,12 @@ const safeArchiveEmptyStarterOrganization = async ( if (teamCountRemainingOnOldOrg > 0) return const org = await dataLoader.get('organizations').loadNonNull(orgId) if (org.tier !== 'starter') return - + await pg + .updateTable('OrganizationUser') + .set({removedAt: sql`CURRENT_TIMESTAMP`}) + .where('orgId', '=', orgId) + .where('removedAt', 'is', null) + .execute() await r .table('OrganizationUser') .getAll(orgId, {index: 'orgId'}) diff --git a/packages/server/utils/__tests__/isRequestToJoinDomainAllowed.test.ts b/packages/server/utils/__tests__/isRequestToJoinDomainAllowed.test.ts index 73df49f1454..08f9fd21b1b 100644 --- a/packages/server/utils/__tests__/isRequestToJoinDomainAllowed.test.ts +++ b/packages/server/utils/__tests__/isRequestToJoinDomainAllowed.test.ts @@ -27,25 +27,9 @@ const testConfig = { db: TEST_DB } -const createTables = async (...tables: string[]) => { - for (const tableName of tables) { - const structure = await r - .db('rethinkdb') - .table('table_config') - .filter({db: config.db, name: tableName}) - .run() - await r.tableCreate(tableName).run() - const {indexes} = structure[0] - for (const index of indexes) { - await r.table(tableName).indexCreate(index).run() - } - await r.table(tableName).indexWait().run() - } -} - type TestOrganizationUser = Partial< Pick -> +> & {userId: string} type TestUser = Insertable const addUsers = async (users: TestUser[]) => { @@ -72,10 +56,14 @@ const addOrg = async ( ...member, inactive: member.inactive ?? false, role: member.role ?? null, - removedAt: member.removedAt ?? null + removedAt: member.removedAt ?? null, + tier: 'starter' as const })) - await getKysely().insertInto('Organization').values(org).execute() - await r.table('OrganizationUser').insert(orgUsers).run() + await getKysely() + .with('Org', (qc) => qc.insertInto('Organization').values(org)) + .insertInto('OrganizationUser') + .values(orgUsers) + .execute() return orgId } @@ -89,13 +77,18 @@ beforeAll(async () => { } await pg.schema.createSchema(TEST_DB).ifNotExists().execute() await r.dbCreate(TEST_DB).run() - await createPGTables('Organization', 'User', 'FreemailDomain', 'SAML', 'SAMLDomain') - await createTables('OrganizationUser') + await createPGTables( + 'Organization', + 'User', + 'FreemailDomain', + 'SAML', + 'SAMLDomain', + 'OrganizationUser' + ) }) afterEach(async () => { - await truncatePGTables('Organization', 'User') - await r.table('OrganizationUser').delete().run() + await truncatePGTables('Organization', 'User', 'OrganizationUser') }) afterAll(async () => { diff --git a/packages/server/utils/authorization.ts b/packages/server/utils/authorization.ts index bcfb150ce19..b2c0f0b17f6 100644 --- a/packages/server/utils/authorization.ts +++ b/packages/server/utils/authorization.ts @@ -1,8 +1,7 @@ import toTeamMemberId from 'parabol-client/utils/relay/toTeamMemberId' import getRethink from '../database/rethinkDriver' -import {RDatum} from '../database/stricterR' import AuthToken from '../database/types/AuthToken' -import OrganizationUser, {OrgUserRole} from '../database/types/OrganizationUser' +import {OrgUserRole} from '../database/types/OrganizationUser' import {DataLoaderWorker} from '../graphql/graphql' export const getUserId = (authToken: any) => { @@ -90,26 +89,3 @@ export const isUserInOrg = async (userId: string, orgId: string, dataLoader: Dat .load({userId, orgId}) return !!organizationUser } - -export const isOrgLeaderOfUser = async (authToken: AuthToken, userId: string) => { - const r = await getRethink() - const viewerId = getUserId(authToken) - const {viewerOrgIds, userOrgIds} = await r({ - viewerOrgIds: r - .table('OrganizationUser') - .getAll(viewerId, {index: 'userId'}) - .filter({removedAt: null}) - .filter((row: RDatum) => r.expr(['BILLING_LEADER', 'ORG_ADMIN']).contains(row('role')))( - 'orgId' - ) - .coerceTo('array') as any as OrganizationUser[], - userOrgIds: r - .table('OrganizationUser') - .getAll(userId, {index: 'userId'}) - .filter({removedAt: null})('orgId') - .coerceTo('array') as any as OrganizationUser[] - }).run() - const uniques = new Set(viewerOrgIds.concat(userOrgIds)) - const total = viewerOrgIds.length + userOrgIds.length - return uniques.size < total -} diff --git a/packages/server/utils/getActiveDomainForOrgId.ts b/packages/server/utils/getActiveDomainForOrgId.ts index 9f5c9f1b7ea..51b8a27a217 100644 --- a/packages/server/utils/getActiveDomainForOrgId.ts +++ b/packages/server/utils/getActiveDomainForOrgId.ts @@ -1,18 +1,13 @@ -import getRethink from '../database/rethinkDriver' +import {DataLoaderInstance} from '../dataloader/RootDataLoader' import getKysely from '../postgres/getKysely' - /** * Most used company domain for a given orgId */ -const getActiveDomainForOrgId = async (orgId: string) => { - const r = await getRethink() - const pg = getKysely() - const userIds = await r - .table('OrganizationUser') - .getAll(orgId, {index: 'orgId'}) - .filter({removedAt: null})('userId') - .run() +const getActiveDomainForOrgId = async (orgId: string, dataLoader: DataLoaderInstance) => { + const pg = getKysely() + const orgUsers = await dataLoader.get('organizationUsersByOrgId').load(orgId) + const userIds = orgUsers.map(({userId}) => userId) const activeDomain = await pg .selectFrom('User') diff --git a/packages/server/utils/setTierForOrgUsers.ts b/packages/server/utils/setTierForOrgUsers.ts index 0983faae06d..8131dd85f1b 100644 --- a/packages/server/utils/setTierForOrgUsers.ts +++ b/packages/server/utils/setTierForOrgUsers.ts @@ -12,13 +12,19 @@ import getKysely from '../postgres/getKysely' const setTierForOrgUsers = async (orgId: string) => { const r = await getRethink() + const pg = getKysely() const organization = await getKysely() .selectFrom('Organization') .select(['trialStartDate', 'tier']) .where('id', '=', orgId) .executeTakeFirstOrThrow() const {tier, trialStartDate} = organization - + await pg + .updateTable('OrganizationUser') + .set({tier, trialStartDate}) + .where('orgId', '=', orgId) + .where('removedAt', 'is', null) + .execute() await r .table('OrganizationUser') .getAll(orgId, {index: 'orgId'}) diff --git a/packages/server/utils/setUserTierForOrgId.ts b/packages/server/utils/setUserTierForOrgId.ts index 65d7470ed54..23e1480070a 100644 --- a/packages/server/utils/setUserTierForOrgId.ts +++ b/packages/server/utils/setUserTierForOrgId.ts @@ -1,8 +1,17 @@ import getRethink from '../database/rethinkDriver' +import getKysely from '../postgres/getKysely' import setUserTierForUserIds from './setUserTierForUserIds' const setUserTierForOrgId = async (orgId: string) => { const r = await getRethink() + const pg = getKysely() + const _userIds = await pg + .selectFrom('OrganizationUser') + .select('userId') + .where('orgId', '=', orgId) + .where('removedAt', 'is', null) + .execute() + console.log({_userIds}) const userIds = await r .table('OrganizationUser') .getAll(orgId, {index: 'orgId'}) diff --git a/packages/server/utils/setUserTierForUserIds.ts b/packages/server/utils/setUserTierForUserIds.ts index 6b22fff26a7..2e1c9dae552 100644 --- a/packages/server/utils/setUserTierForUserIds.ts +++ b/packages/server/utils/setUserTierForUserIds.ts @@ -12,7 +12,13 @@ import {analytics} from './analytics/analytics' const setUserTierForUserId = async (userId: string) => { const r = await getRethink() const pg = getKysely() - + const _orgUsers = await pg + .selectFrom('OrganizationUser') + .selectAll() + .where('userId', '=', userId) + .where('removedAt', 'is', null) + .execute() + console.log({_orgUsers}) const orgUsers = await r .table('OrganizationUser') .getAll(userId, {index: 'userId'}) diff --git a/scripts/toolboxSrc/setIsEnterprise.ts b/scripts/toolboxSrc/setIsEnterprise.ts index 53e9ebf370b..9fe23b5cfeb 100644 --- a/scripts/toolboxSrc/setIsEnterprise.ts +++ b/scripts/toolboxSrc/setIsEnterprise.ts @@ -1,6 +1,4 @@ import getKysely from 'parabol-server/postgres/getKysely' -import getRethink from '../../packages/server/database/rethinkDriver' -import getPg from '../../packages/server/postgres/getPg' import {defaultTier} from '../../packages/server/utils/defaultTier' export default async function setIsEnterprise() { @@ -10,43 +8,17 @@ export default async function setIsEnterprise() { ) } - const r = await getRethink() - - console.log( - 'Updating tier to "enterprise" for Organization and OrganizationUser tables in RethinkDB' - ) - - await getKysely().updateTable('Organization').set({tier: 'enterprise'}).execute() - await r - .table('OrganizationUser') - .update({ - tier: 'enterprise' - }) - .run() - - const pg = getPg() - - const updateUserPromise = pg - .query(`UPDATE "User" SET tier = 'enterprise' RETURNING id`) - .then((res) => { - console.log('Updated User tier to enterprise for:', res.rows.length, 'records in PostgreSQL.') - return res - }) - - const updateTeamPromise = pg - .query(`UPDATE "Team" SET tier = 'enterprise' RETURNING id`) - .then((res) => { - console.log('Updated Team tier to enterprise for:', res.rows.length, 'records in PostgreSQL.') - return res - }) - - const pgPromises = [updateUserPromise, updateTeamPromise] - - await Promise.all(pgPromises) + const pg = getKysely() + await Promise.all([ + pg.updateTable('Organization').set({tier: 'enterprise'}).execute(), + pg.updateTable('OrganizationUser').set({tier: 'enterprise'}).execute(), + pg.updateTable('User').set({tier: 'enterprise'}).execute(), + pg.updateTable('Team').set({tier: 'enterprise'}).execute() + ]) console.log('Finished updating tiers.') - await pg.end() + await pg.destroy() process.exit() } From ca01882ef28497a3d64bbbe0fb93daf8f5ebe6a4 Mon Sep 17 00:00:00 2001 From: Matt Krick Date: Wed, 10 Jul 2024 12:06:31 -0700 Subject: [PATCH 38/47] fix: bad merge Signed-off-by: Matt Krick --- .release-please-manifest.json | 2 +- CHANGELOG.md | 14 ++++++++++++++ package.json | 2 +- packages/chronos/package.json | 4 ++-- packages/client/package.json | 2 +- packages/embedder/package.json | 2 +- packages/gql-executor/package.json | 6 +++--- packages/integration-tests/package.json | 2 +- packages/server/package.json | 4 ++-- 9 files changed, 26 insertions(+), 12 deletions(-) diff --git a/.release-please-manifest.json b/.release-please-manifest.json index e86a0483340..c4a1821f12c 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -1,3 +1,3 @@ { - ".": "7.38.2" + ".": "7.38.4" } diff --git a/CHANGELOG.md b/CHANGELOG.md index 366de6d9689..2601de361fb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,20 @@ This project adheres to [Semantic Versioning](http://semver.org/). This CHANGELOG follows conventions [outlined here](http://keepachangelog.com/). +## [7.38.4](https://github.com/ParabolInc/parabol/compare/v7.38.3...v7.38.4) (2024-07-10) + + +### Changed + +* **rethinkdb:** Organization: Phase 3 ([#9933](https://github.com/ParabolInc/parabol/issues/9933)) ([70084f8](https://github.com/ParabolInc/parabol/commit/70084f86b1832dc087b0bf7eb279253b61dacf01)) + +## [7.38.3](https://github.com/ParabolInc/parabol/compare/v7.38.2...v7.38.3) (2024-07-09) + + +### Changed + +* **rethinkdb:** phase 4 of RetroReflection, RetroReflectionGroup and TimelineEvent ([#9943](https://github.com/ParabolInc/parabol/issues/9943)) ([151b029](https://github.com/ParabolInc/parabol/commit/151b0298013837c912bd2c58226519196d800a94)) + ## [7.38.2](https://github.com/ParabolInc/parabol/compare/v7.38.1...v7.38.2) (2024-07-08) diff --git a/package.json b/package.json index a146f79baa4..909851fd1e5 100644 --- a/package.json +++ b/package.json @@ -3,7 +3,7 @@ "description": "An open-source app for building smarter, more agile teams.", "author": "Parabol Inc. (http://github.com/ParabolInc)", "license": "AGPL-3.0", - "version": "7.38.2", + "version": "7.38.4", "repository": { "type": "git", "url": "https://github.com/ParabolInc/parabol" diff --git a/packages/chronos/package.json b/packages/chronos/package.json index 77014d2c977..3c5a4b33f83 100644 --- a/packages/chronos/package.json +++ b/packages/chronos/package.json @@ -1,6 +1,6 @@ { "name": "chronos", - "version": "7.38.2", + "version": "7.38.4", "description": "A cron job scheduler", "author": "Matt Krick ", "homepage": "https://github.com/ParabolInc/parabol/tree/master/packages/chronos#readme", @@ -25,6 +25,6 @@ }, "dependencies": { "cron": "^2.3.1", - "parabol-server": "7.38.2" + "parabol-server": "7.38.4" } } diff --git a/packages/client/package.json b/packages/client/package.json index 1d1568631b4..20667bb99ce 100644 --- a/packages/client/package.json +++ b/packages/client/package.json @@ -3,7 +3,7 @@ "description": "An open-source app for building smarter, more agile teams.", "author": "Parabol Inc. (http://github.com/ParabolInc)", "license": "AGPL-3.0", - "version": "7.38.2", + "version": "7.38.4", "repository": { "type": "git", "url": "https://github.com/ParabolInc/parabol" diff --git a/packages/embedder/package.json b/packages/embedder/package.json index ddee4edcb19..fb3a3e9c785 100644 --- a/packages/embedder/package.json +++ b/packages/embedder/package.json @@ -1,6 +1,6 @@ { "name": "parabol-embedder", - "version": "7.38.2", + "version": "7.38.4", "description": "A service that computes embedding vectors from Parabol objects", "author": "Jordan Husney ", "homepage": "https://github.com/ParabolInc/parabol/tree/master/packages/embedder#readme", diff --git a/packages/gql-executor/package.json b/packages/gql-executor/package.json index 7956b82562d..0cd6dd3a29e 100644 --- a/packages/gql-executor/package.json +++ b/packages/gql-executor/package.json @@ -1,6 +1,6 @@ { "name": "gql-executor", - "version": "7.38.2", + "version": "7.38.4", "description": "A Stateless GraphQL Executor", "author": "Matt Krick ", "homepage": "https://github.com/ParabolInc/parabol/tree/master/packages/gqlExecutor#readme", @@ -27,8 +27,8 @@ }, "dependencies": { "dd-trace": "^4.2.0", - "parabol-client": "7.38.2", - "parabol-server": "7.38.2", + "parabol-client": "7.38.4", + "parabol-server": "7.38.4", "undici": "^5.26.2" } } diff --git a/packages/integration-tests/package.json b/packages/integration-tests/package.json index b99877d08d6..5ac66baa82d 100644 --- a/packages/integration-tests/package.json +++ b/packages/integration-tests/package.json @@ -2,7 +2,7 @@ "name": "integration-tests", "author": "Parabol Inc. (http://github.com/ParabolInc)", "license": "AGPL-3.0", - "version": "7.38.2", + "version": "7.38.4", "description": "", "main": "index.js", "scripts": { diff --git a/packages/server/package.json b/packages/server/package.json index 06fb050e2e1..4cf59fd1831 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -3,7 +3,7 @@ "description": "An open-source app for building smarter, more agile teams.", "author": "Parabol Inc. (http://github.com/ParabolInc)", "license": "AGPL-3.0", - "version": "7.38.2", + "version": "7.38.4", "repository": { "type": "git", "url": "https://github.com/ParabolInc/parabol" @@ -124,7 +124,7 @@ "openai": "^4.24.1", "openapi-fetch": "^0.9.7", "oy-vey": "^0.12.1", - "parabol-client": "7.38.2", + "parabol-client": "7.38.4", "pg": "^8.5.1", "react": "^17.0.2", "react-dom": "^17.0.2", From 44d153ebe6c14877ca7185d9cb9d63445b37e6cf Mon Sep 17 00:00:00 2001 From: Matt Krick Date: Wed, 10 Jul 2024 12:43:35 -0700 Subject: [PATCH 39/47] re-add rethinkdb to server tests Signed-off-by: Matt Krick --- .../__tests__/isOrgVerified.test.ts | 21 ++++++++++++++++++- .../1720556055134_OrganizationUser-phase1.ts | 1 - .../isRequestToJoinDomainAllowed.test.ts | 18 ++++++++++++++++ 3 files changed, 38 insertions(+), 2 deletions(-) diff --git a/packages/server/dataloader/__tests__/isOrgVerified.test.ts b/packages/server/dataloader/__tests__/isOrgVerified.test.ts index 93a90d900d0..d6d337c7657 100644 --- a/packages/server/dataloader/__tests__/isOrgVerified.test.ts +++ b/packages/server/dataloader/__tests__/isOrgVerified.test.ts @@ -35,6 +35,22 @@ const addUsers = async (users: TestUser[]) => { getKysely().insertInto('User').values(users).execute() } +const createTables = async (...tables: string[]) => { + for (const tableName of tables) { + const structure = await r + .db('rethinkdb') + .table('table_config') + .filter({db: config.db, name: tableName}) + .run() + await r.tableCreate(tableName).run() + const {indexes} = structure[0] + for (const index of indexes) { + await r.table(tableName).indexCreate(index).run() + } + await r.table(tableName).indexWait().run() + } +} + type TestOrganizationUser = Partial< Pick & { domain: string @@ -71,6 +87,7 @@ const addOrg = async ( .insertInto('OrganizationUser') .values(orgUsers) .execute() + await r.table('OrganizationUser').insert(orgUsers).run() return orgId } @@ -87,10 +104,12 @@ beforeAll(async () => { await r.dbCreate(TEST_DB).run() await createPGTables('Organization', 'User', 'SAML', 'SAMLDomain', 'OrganizationUser') + await createTables('OrganizationUser') }) afterEach(async () => { - await truncatePGTables('Organization', 'User', 'OrganizationUser') + await truncatePGTables('Organization', 'User') + await r.table('OrganizationUser').delete().run() }) afterAll(async () => { diff --git a/packages/server/postgres/migrations/1720556055134_OrganizationUser-phase1.ts b/packages/server/postgres/migrations/1720556055134_OrganizationUser-phase1.ts index adbf88b2279..0d348909bff 100644 --- a/packages/server/postgres/migrations/1720556055134_OrganizationUser-phase1.ts +++ b/packages/server/postgres/migrations/1720556055134_OrganizationUser-phase1.ts @@ -18,7 +18,6 @@ export async function up() { "suggestedTier" "TierEnum", "inactive" BOOLEAN NOT NULL DEFAULT FALSE, "joinedAt" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(), - "newUserUntil" TIMESTAMP WITH TIME ZONE, "orgId" VARCHAR(100) NOT NULL, "removedAt" TIMESTAMP WITH TIME ZONE, "role" "OrgUserRoleEnum", diff --git a/packages/server/utils/__tests__/isRequestToJoinDomainAllowed.test.ts b/packages/server/utils/__tests__/isRequestToJoinDomainAllowed.test.ts index 08f9fd21b1b..d0d5d0e2145 100644 --- a/packages/server/utils/__tests__/isRequestToJoinDomainAllowed.test.ts +++ b/packages/server/utils/__tests__/isRequestToJoinDomainAllowed.test.ts @@ -27,6 +27,22 @@ const testConfig = { db: TEST_DB } +const createTables = async (...tables: string[]) => { + for (const tableName of tables) { + const structure = await r + .db('rethinkdb') + .table('table_config') + .filter({db: config.db, name: tableName}) + .run() + await r.tableCreate(tableName).run() + const {indexes} = structure[0] + for (const index of indexes) { + await r.table(tableName).indexCreate(index).run() + } + await r.table(tableName).indexWait().run() + } +} + type TestOrganizationUser = Partial< Pick > & {userId: string} @@ -85,10 +101,12 @@ beforeAll(async () => { 'SAMLDomain', 'OrganizationUser' ) + await createTables('OrganizationUser') }) afterEach(async () => { await truncatePGTables('Organization', 'User', 'OrganizationUser') + await r.table('OrganizationUser').delete().run() }) afterAll(async () => { From 303f493473f8538ab6be4c1aaf6de386519abc65 Mon Sep 17 00:00:00 2001 From: Matt Krick Date: Wed, 10 Jul 2024 13:05:32 -0700 Subject: [PATCH 40/47] chore: migrate existing data Signed-off-by: Matt Krick --- .../mutations/checkRethinkPgEquality.ts | 62 +++------- .../1720640784862_OrganizationUser-phase2.ts | 110 ++++++++++++++++++ packages/server/postgres/utils/checkEqBase.ts | 4 +- 3 files changed, 128 insertions(+), 48 deletions(-) create mode 100644 packages/server/postgres/migrations/1720640784862_OrganizationUser-phase2.ts diff --git a/packages/server/graphql/private/mutations/checkRethinkPgEquality.ts b/packages/server/graphql/private/mutations/checkRethinkPgEquality.ts index a01373429eb..47c8215ed43 100644 --- a/packages/server/graphql/private/mutations/checkRethinkPgEquality.ts +++ b/packages/server/graphql/private/mutations/checkRethinkPgEquality.ts @@ -2,17 +2,7 @@ import getRethink from '../../../database/rethinkDriver' import getFileStoreManager from '../../../fileStorage/getFileStoreManager' import getKysely from '../../../postgres/getKysely' import {checkRowCount, checkTableEq} from '../../../postgres/utils/checkEqBase' -import { - compareDateAlmostEqual, - compareRValOptionalPluckedObject, - compareRValStringAsNumber, - compareRValUndefinedAsEmptyArray, - compareRValUndefinedAsFalse, - compareRValUndefinedAsNull, - compareRValUndefinedAsNullAndTruncateRVal, - compareRValUndefinedAsZero, - defaultEqFn -} from '../../../postgres/utils/rethinkEqualityFns' +import {compareRValUndefinedAsNull, defaultEqFn} from '../../../postgres/utils/rethinkEqualityFns' import {MutationResolvers} from '../resolverTypes' const handleResult = async ( @@ -37,55 +27,35 @@ const checkRethinkPgEquality: MutationResolvers['checkRethinkPgEquality'] = asyn ) => { const r = await getRethink() - if (tableName === 'Organization') { + if (tableName === 'OrganizationUser') { const rowCountResult = await checkRowCount(tableName) - const rethinkQuery = (updatedAt: Date, id: string | number) => { + const rethinkQuery = (joinedAt: Date, id: string | number) => { return r - .table('Organization' as any) - .between([updatedAt, id], [r.maxval, r.maxval], { - index: 'updatedAtId', + .table('OrganizationUser' as any) + .between([joinedAt, id], [r.maxval, r.maxval], { + index: 'joinedAtId', leftBound: 'open', rightBound: 'closed' }) - .orderBy({index: 'updatedAtId'}) as any + .orderBy({index: 'joinedAtId'}) as any } const pgQuery = async (ids: string[]) => { - return getKysely() - .selectFrom('Organization') - .selectAll() - .select(({fn}) => [fn('to_json', ['creditCard']).as('creditCard')]) - .where('id', 'in', ids) - .execute() + return getKysely().selectFrom('OrganizationUser').selectAll().where('id', 'in', ids).execute() } const errors = await checkTableEq( rethinkQuery, pgQuery, { id: defaultEqFn, - activeDomain: compareRValUndefinedAsNullAndTruncateRVal(100), - isActiveDomainTouched: compareRValUndefinedAsFalse, - creditCard: compareRValOptionalPluckedObject({ - brand: compareRValUndefinedAsNull, - expiry: compareRValUndefinedAsNull, - last4: compareRValStringAsNumber - }), - createdAt: defaultEqFn, - name: compareRValUndefinedAsNullAndTruncateRVal(100), - payLaterClickCount: compareRValUndefinedAsZero, - periodEnd: compareRValUndefinedAsNull, - periodStart: compareRValUndefinedAsNull, - picture: compareRValUndefinedAsNull, - showConversionModal: compareRValUndefinedAsFalse, - stripeId: compareRValUndefinedAsNull, - stripeSubscriptionId: compareRValUndefinedAsNull, - upcomingInvoiceEmailSentAt: compareRValUndefinedAsNull, + suggestedTier: compareRValUndefinedAsNull, + inactive: defaultEqFn, + joinedAt: defaultEqFn, + orgId: defaultEqFn, + removedAt: defaultEqFn, + role: compareRValUndefinedAsNull, + userId: defaultEqFn, tier: defaultEqFn, - tierLimitExceededAt: compareRValUndefinedAsNull, - trialStartDate: compareRValUndefinedAsNull, - scheduledLockAt: compareRValUndefinedAsNull, - lockedAt: compareRValUndefinedAsNull, - updatedAt: compareDateAlmostEqual, - featureFlags: compareRValUndefinedAsEmptyArray + trialStartDate: compareRValUndefinedAsNull }, maxErrors ) diff --git a/packages/server/postgres/migrations/1720640784862_OrganizationUser-phase2.ts b/packages/server/postgres/migrations/1720640784862_OrganizationUser-phase2.ts new file mode 100644 index 00000000000..ddeb041d9fc --- /dev/null +++ b/packages/server/postgres/migrations/1720640784862_OrganizationUser-phase2.ts @@ -0,0 +1,110 @@ +import {Kysely, PostgresDialect} from 'kysely' +import {r} from 'rethinkdb-ts' +import connectRethinkDB from '../../database/connectRethinkDB' +import getPg from '../getPg' + +export async function up() { + await connectRethinkDB() + const pg = new Kysely({ + dialect: new PostgresDialect({ + pool: getPg() + }) + }) + try { + console.log('Adding index') + await r + .table('OrganizationUser') + .indexCreate('joinedAtId', (row: any) => [row('joinedAt'), row('id')]) + .run() + await r.table('OrganizationUser').indexWait().run() + } catch { + // index already exists + } + await r.table('OrganizationUser').get('aGhostOrganizationUser').update({tier: 'enterprise'}).run() + await console.log('Adding index complete') + const MAX_PG_PARAMS = 65545 + const PG_COLS = [ + 'id', + 'suggestedTier', + 'inactive', + 'joinedAt', + 'orgId', + 'removedAt', + 'role', + 'userId', + 'tier', + 'trialStartDate' + ] as const + type OrganizationUser = { + [K in (typeof PG_COLS)[number]]: any + } + const BATCH_SIZE = Math.trunc(MAX_PG_PARAMS / PG_COLS.length) + + let curjoinedAt = r.minval + let curId = r.minval + for (let i = 0; i < 1e6; i++) { + console.log('inserting row', i * BATCH_SIZE, curjoinedAt, curId) + const rawRowsToInsert = (await r + .table('OrganizationUser') + .between([curjoinedAt, curId], [r.maxval, r.maxval], { + index: 'joinedAtId', + leftBound: 'open', + rightBound: 'closed' + }) + .orderBy({index: 'joinedAtId'}) + .limit(BATCH_SIZE) + .pluck(...PG_COLS) + .run()) as OrganizationUser[] + + const rowsToInsert = rawRowsToInsert.map((row) => { + const {newUserUntil, ...rest} = row as any + return { + ...rest + } + }) + if (rowsToInsert.length === 0) break + const lastRow = rowsToInsert[rowsToInsert.length - 1] + curjoinedAt = lastRow.joinedAt + curId = lastRow.id + try { + await pg + .insertInto('OrganizationUser') + .values(rowsToInsert) + .onConflict((oc) => oc.doNothing()) + .execute() + } catch (e) { + await Promise.all( + rowsToInsert.map(async (row) => { + try { + await pg + .insertInto('OrganizationUser') + .values(row) + .onConflict((oc) => oc.doNothing()) + .execute() + } catch (e) { + if (e.constraint === 'fk_userId' || e.constraint === 'fk_orgId') { + console.log(`Skipping ${row.id} because it has no user/org`) + return + } + console.log(e, row) + } + }) + ) + } + } +} + +export async function down() { + await connectRethinkDB() + try { + await r.table('OrganizationUser').indexDrop('joinedAtId').run() + } catch { + // index already dropped + } + const pg = new Kysely({ + dialect: new PostgresDialect({ + pool: getPg() + }) + }) + await pg.deleteFrom('OrganizationUser').execute() +} diff --git a/packages/server/postgres/utils/checkEqBase.ts b/packages/server/postgres/utils/checkEqBase.ts index 175d20a57a5..bbe31117b3f 100644 --- a/packages/server/postgres/utils/checkEqBase.ts +++ b/packages/server/postgres/utils/checkEqBase.ts @@ -33,7 +33,7 @@ export const checkRowCount = async (tableName: string) => { } export async function checkTableEq( - rethinkQuery: (updatedAt: Date, id: string | number) => RSelection, + rethinkQuery: (joinedAt: Date, id: string | number) => RSelection, pgQuery: (ids: string[]) => Promise, equalityMap: Record boolean>, maxErrors: number | null | undefined @@ -51,7 +51,7 @@ export async function checkTableEq( .run()) as RethinkDoc[] if (rethinkRows.length === 0) break const lastRow = rethinkRows[rethinkRows.length - 1]! - curUpdatedDate = lastRow.updatedAt + curUpdatedDate = lastRow.joinedAt curId = lastRow.id const ids = rethinkRows.map((t) => t.id) const pgRows = (await pgQuery(ids)) ?? [] From 3c3b46cb1f0f6f0c49f19712eaaba80e3dd4e18d Mon Sep 17 00:00:00 2001 From: Matt Krick Date: Wed, 10 Jul 2024 13:40:19 -0700 Subject: [PATCH 41/47] fix tests Signed-off-by: Matt Krick --- .../__tests__/isOrgVerified.test.ts | 47 +++-- .../isRequestToJoinDomainAllowed.test.ts | 181 ++++++++++-------- 2 files changed, 129 insertions(+), 99 deletions(-) diff --git a/packages/server/dataloader/__tests__/isOrgVerified.test.ts b/packages/server/dataloader/__tests__/isOrgVerified.test.ts index d6d337c7657..8f351adae5a 100644 --- a/packages/server/dataloader/__tests__/isOrgVerified.test.ts +++ b/packages/server/dataloader/__tests__/isOrgVerified.test.ts @@ -32,7 +32,7 @@ const testConfig = { type TestUser = Insertable const addUsers = async (users: TestUser[]) => { - getKysely().insertInto('User').values(users).execute() + return getKysely().insertInto('User').values(users).execute() } const createTables = async (...tables: string[]) => { @@ -52,9 +52,7 @@ const createTables = async (...tables: string[]) => { } type TestOrganizationUser = Partial< - Pick & { - domain: string - } + Pick > const addOrg = async ( @@ -82,11 +80,16 @@ const addOrg = async ( })) const pg = getKysely() - await pg - .with('Org', (qc) => qc.insertInto('Organization').values(org)) - .insertInto('OrganizationUser') - .values(orgUsers) - .execute() + if (orgUsers.length > 0) { + await pg + .with('Org', (qc) => qc.insertInto('Organization').values(org)) + .insertInto('OrganizationUser') + .values(orgUsers) + .execute() + } else { + await pg.insertInto('Organization').values(org).execute() + } + await r.table('OrganizationUser').insert(orgUsers).run() return orgId } @@ -114,17 +117,11 @@ afterEach(async () => { afterAll(async () => { await r.getPoolMaster()?.drain() + await getKysely().destroy() getRedis().quit() }) test('Founder is billing lead', async () => { - await addOrg('parabol.co', [ - { - joinedAt: new Date('2023-09-06'), - role: 'BILLING_LEADER', - userId: 'user1' - } - ]) await addUsers([ { id: 'user1', @@ -134,6 +131,14 @@ test('Founder is billing lead', async () => { identities: [{isEmailVerified: true}] } ]) + await addOrg('parabol.co', [ + { + joinedAt: new Date('2023-09-06'), + role: 'BILLING_LEADER', + userId: 'user1' + } + ]) + const dataLoader = new RootDataLoader() const isVerified = await dataLoader.get('isOrgVerified').load('parabol.co') expect(isVerified).toBe(true) @@ -154,6 +159,13 @@ test('Non-founder billing lead is checked', async () => { picture: '', preferredName: '', identities: [{isEmailVerified: true}] + }, + { + id: 'member1', + email: 'member1@parabol.co', + picture: '', + preferredName: '', + identities: [{isEmailVerified: true}] } ]) await addOrg('parabol.co', [ @@ -200,8 +212,7 @@ test('Orgs with verified emails from different domains do not qualify', async () await addOrg('parabol.co', [ { joinedAt: new Date('2023-09-06'), - userId: 'founder1', - domain: 'not-parabol.co' + userId: 'founder1' } as any ]) diff --git a/packages/server/utils/__tests__/isRequestToJoinDomainAllowed.test.ts b/packages/server/utils/__tests__/isRequestToJoinDomainAllowed.test.ts index d0d5d0e2145..a17af53e9aa 100644 --- a/packages/server/utils/__tests__/isRequestToJoinDomainAllowed.test.ts +++ b/packages/server/utils/__tests__/isRequestToJoinDomainAllowed.test.ts @@ -49,7 +49,7 @@ type TestOrganizationUser = Partial< type TestUser = Insertable const addUsers = async (users: TestUser[]) => { - getKysely().insertInto('User').values(users).execute() + return getKysely().insertInto('User').values(users).execute() } const addOrg = async ( activeDomain: string | null, @@ -80,6 +80,7 @@ const addOrg = async ( .insertInto('OrganizationUser') .values(orgUsers) .execute() + await r.table('OrganizationUser').insert(orgUsers).run() return orgId } @@ -111,10 +112,64 @@ afterEach(async () => { afterAll(async () => { await r.getPoolMaster()?.drain() + await getKysely().destroy() getRedis().quit() }) test('Only the biggest org with verified emails qualify', async () => { + await addUsers([ + { + id: 'founder1', + email: 'user1@parabol.co', + picture: '', + preferredName: 'user1', + identities: [ + { + isEmailVerified: true + } + ] + }, + { + id: 'founder2', + email: 'user2@parabol.co', + picture: '', + preferredName: 'user2', + identities: [ + { + isEmailVerified: true + } + ] + }, + { + id: 'founder3', + email: 'user3@parabol.co', + picture: '', + preferredName: 'user3', + identities: [ + { + isEmailVerified: false + } + ] + }, + { + id: 'member1', + email: 'member1@parabol.co', + picture: '', + preferredName: '' + }, + { + id: 'member2', + email: 'member2@parabol.co', + picture: '', + preferredName: '' + }, + { + id: 'member3', + email: 'member3@parabol.co', + picture: '', + preferredName: '' + } + ]) await addOrg('parabol.co', [ { joinedAt: new Date('2023-09-06'), @@ -152,7 +207,13 @@ test('Only the biggest org with verified emails qualify', async () => { userId: 'member3' } ]) - addUsers([ + const dataLoader = new RootDataLoader() + const orgIds = await getEligibleOrgIdsByDomain('parabol.co', 'newUser', dataLoader) + expect(orgIds).toIncludeSameMembers([biggerOrg]) +}) + +test('All the biggest orgs with verified emails qualify', async () => { + await addUsers([ { id: 'founder1', email: 'user1@parabol.co', @@ -187,12 +248,6 @@ test('Only the biggest org with verified emails qualify', async () => { ] } ]) - const dataLoader = new RootDataLoader() - const orgIds = await getEligibleOrgIdsByDomain('parabol.co', 'newUser', dataLoader) - expect(orgIds).toIncludeSameMembers([biggerOrg]) -}) - -test('All the biggest orgs with verified emails qualify', async () => { const org1 = await addOrg('parabol.co', [ { joinedAt: new Date('2023-09-06'), @@ -226,6 +281,13 @@ test('All the biggest orgs with verified emails qualify', async () => { userId: 'member3' } ]) + + const dataLoader = new RootDataLoader() + const orgIds = await getEligibleOrgIdsByDomain('parabol.co', 'newUser', dataLoader) + expect(orgIds).toIncludeSameMembers([org1, org2]) +}) + +test('Team trumps starter tier with more users org', async () => { await addUsers([ { id: 'founder1', @@ -261,13 +323,6 @@ test('All the biggest orgs with verified emails qualify', async () => { ] } ]) - - const dataLoader = new RootDataLoader() - const orgIds = await getEligibleOrgIdsByDomain('parabol.co', 'newUser', dataLoader) - expect(orgIds).toIncludeSameMembers([org1, org2]) -}) - -test('Team trumps starter tier with more users org', async () => { const teamOrg = await addOrg( 'parabol.co', [ @@ -310,6 +365,12 @@ test('Team trumps starter tier with more users org', async () => { } ]) + const dataLoader = new RootDataLoader() + const orgIds = await getEligibleOrgIdsByDomain('parabol.co', 'newUser', dataLoader) + expect(orgIds).toIncludeSameMembers([teamOrg]) +}) + +test('Enterprise trumps team tier with more users org', async () => { await addUsers([ { id: 'founder1', @@ -345,12 +406,6 @@ test('Team trumps starter tier with more users org', async () => { ] } ]) - const dataLoader = new RootDataLoader() - const orgIds = await getEligibleOrgIdsByDomain('parabol.co', 'newUser', dataLoader) - expect(orgIds).toIncludeSameMembers([teamOrg]) -}) - -test('Enterprise trumps team tier with more users org', async () => { const enterpriseOrg = await addOrg( 'parabol.co', [ @@ -397,10 +452,16 @@ test('Enterprise trumps team tier with more users org', async () => { } ]) + const dataLoader = new RootDataLoader() + const orgIds = await getEligibleOrgIdsByDomain('parabol.co', 'newUser', dataLoader) + expect(orgIds).toIncludeSameMembers([enterpriseOrg]) +}) + +test('Orgs with verified emails from different domains do not qualify', async () => { await addUsers([ { id: 'founder1', - email: 'user1@parabol.co', + email: 'user1@parabol.fun', picture: '', preferredName: 'user1', identities: [ @@ -408,36 +469,9 @@ test('Enterprise trumps team tier with more users org', async () => { isEmailVerified: true } ] - }, - { - id: 'founder2', - email: 'user2@parabol.co', - picture: '', - preferredName: 'user2', - identities: [ - { - isEmailVerified: true - } - ] - }, - { - id: 'founder3', - email: 'user3@parabol.co', - picture: '', - preferredName: 'user3', - identities: [ - { - isEmailVerified: false - } - ] } ]) - const dataLoader = new RootDataLoader() - const orgIds = await getEligibleOrgIdsByDomain('parabol.co', 'newUser', dataLoader) - expect(orgIds).toIncludeSameMembers([enterpriseOrg]) -}) -test('Orgs with verified emails from different domains do not qualify', async () => { await addOrg('parabol.co', [ { joinedAt: new Date('2023-09-06'), @@ -449,44 +483,12 @@ test('Orgs with verified emails from different domains do not qualify', async () } ]) - await addUsers([ - { - id: 'founder1', - email: 'user1@parabol.fun', - picture: '', - preferredName: 'user1', - identities: [ - { - isEmailVerified: true - } - ] - } - ]) - const dataLoader = new RootDataLoader() const orgIds = await getEligibleOrgIdsByDomain('parabol.co', 'newUser', dataLoader) expect(orgIds).toIncludeSameMembers([]) }) test('Orgs with at least 1 verified billing lead with correct email qualify', async () => { - const org1 = await addOrg('parabol.co', [ - { - joinedAt: new Date('2023-09-06'), - userId: 'user1', - role: 'BILLING_LEADER' - }, - { - joinedAt: new Date('2023-09-07'), - userId: 'user2', - role: 'BILLING_LEADER' - }, - { - joinedAt: new Date('2023-09-08'), - userId: 'user3', - role: 'BILLING_LEADER' - } - ]) - await addUsers([ { id: 'user1', @@ -522,6 +524,23 @@ test('Orgs with at least 1 verified billing lead with correct email qualify', as ] } ]) + const org1 = await addOrg('parabol.co', [ + { + joinedAt: new Date('2023-09-06'), + userId: 'user1', + role: 'BILLING_LEADER' + }, + { + joinedAt: new Date('2023-09-07'), + userId: 'user2', + role: 'BILLING_LEADER' + }, + { + joinedAt: new Date('2023-09-08'), + userId: 'user3', + role: 'BILLING_LEADER' + } + ]) const dataLoader = new RootDataLoader() const orgIds = await getEligibleOrgIdsByDomain('parabol.co', 'newUser', dataLoader) From 3e3634c748606fec9dbf3e589644a76175178e18 Mon Sep 17 00:00:00 2001 From: Matt Krick Date: Wed, 10 Jul 2024 13:54:16 -0700 Subject: [PATCH 42/47] fix: use test schema Signed-off-by: Matt Krick --- packages/server/dataloader/__tests__/isOrgVerified.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/server/dataloader/__tests__/isOrgVerified.test.ts b/packages/server/dataloader/__tests__/isOrgVerified.test.ts index 8f351adae5a..6fa0c0c1b0e 100644 --- a/packages/server/dataloader/__tests__/isOrgVerified.test.ts +++ b/packages/server/dataloader/__tests__/isOrgVerified.test.ts @@ -96,7 +96,7 @@ const addOrg = async ( beforeAll(async () => { await r.connectPool(testConfig) - const pg = getKysely() + const pg = getKysely(TEST_DB) try { await r.dbDrop(TEST_DB).run() From 8d33999279ea89e03de31ed27608f7bc89df4b91 Mon Sep 17 00:00:00 2001 From: Matt Krick Date: Wed, 10 Jul 2024 15:18:57 -0700 Subject: [PATCH 43/47] fix: safer queries Signed-off-by: Matt Krick --- packages/server/graphql/graphql.ts | 1 + .../graphql/private/queries/suOrgCount.ts | 21 +++++++----- .../graphql/private/queries/suProOrgInfo.ts | 4 ++- .../public/mutations/setOrgUserRole.ts | 8 ++--- .../subscriptions/organizationSubscription.ts | 33 +++++++++++-------- 5 files changed, 41 insertions(+), 26 deletions(-) diff --git a/packages/server/graphql/graphql.ts b/packages/server/graphql/graphql.ts index ebf6702751b..cd69285748f 100644 --- a/packages/server/graphql/graphql.ts +++ b/packages/server/graphql/graphql.ts @@ -15,6 +15,7 @@ export interface GQLContext { dataLoader: DataLoaderWorker } +export type SubscriptionContext = Omit export interface InternalContext { dataLoader: DataLoaderWorker authToken: AuthToken diff --git a/packages/server/graphql/private/queries/suOrgCount.ts b/packages/server/graphql/private/queries/suOrgCount.ts index ab86ce2fcd2..b2dd6f6afc3 100644 --- a/packages/server/graphql/private/queries/suOrgCount.ts +++ b/packages/server/graphql/private/queries/suOrgCount.ts @@ -6,14 +6,19 @@ import {QueryResolvers} from '../resolverTypes' const suOrgCount: QueryResolvers['suOrgCount'] = async (_source, {minOrgSize, tier}) => { const pg = getKysely() const pgResults = await pg - .selectFrom('OrganizationUser') - .select(({fn}) => fn.count('id').as('orgSize')) - .where('tier', '=', tier) - .where('inactive', '=', false) - .where('removedAt', 'is', null) - .groupBy('orgId') - .having(({eb, fn}) => eb(fn.count('id'), '>=', minOrgSize)) - .execute() + .with('BigOrgs', (qb) => + qb + .selectFrom('OrganizationUser') + .select(({fn}) => fn.count('id').as('orgSize')) + .where('tier', '=', tier) + .where('inactive', '=', false) + .where('removedAt', 'is', null) + .groupBy('orgId') + .having(({eb, fn}) => eb(fn.count('id'), '>=', minOrgSize)) + ) + .selectFrom('BigOrgs') + .select(({fn}) => fn.count('orgSize').as('count')) + .executeTakeFirstOrThrow() // TEST in Phase 2! console.log(pgResults) diff --git a/packages/server/graphql/private/queries/suProOrgInfo.ts b/packages/server/graphql/private/queries/suProOrgInfo.ts index 51a444b97e5..3e604f7a5bb 100644 --- a/packages/server/graphql/private/queries/suProOrgInfo.ts +++ b/packages/server/graphql/private/queries/suProOrgInfo.ts @@ -1,3 +1,4 @@ +import {sql} from 'kysely' import getRethink from '../../../database/rethinkDriver' import {RDatum} from '../../../database/stricterR' import {selectOrganizations} from '../../../dataloader/primaryKeyLoaderMakers' @@ -14,7 +15,8 @@ const suProOrgInfo: QueryResolvers['suProOrgInfo'] = async (_source, {includeIna const pgResults = await pg .selectFrom('OrganizationUser') .select(({fn}) => fn.count('id').as('orgSize')) - .where('orgId', 'in', proOrgIds) + // use ANY to support case where proOrgIds is empty array. Please use `in` after RethinkDB is gone + .where('orgId', '=', sql`ANY(${proOrgIds})`) .where('inactive', '=', false) .where('removedAt', 'is', null) .groupBy('orgId') diff --git a/packages/server/graphql/public/mutations/setOrgUserRole.ts b/packages/server/graphql/public/mutations/setOrgUserRole.ts index 7be2a4f0b32..d00522221fe 100644 --- a/packages/server/graphql/public/mutations/setOrgUserRole.ts +++ b/packages/server/graphql/public/mutations/setOrgUserRole.ts @@ -63,14 +63,14 @@ const setOrgUserRole: MutationResolvers['setOrgUserRole'] = async ( } } - // if someone is leaving, make sure there is someone else to take their place - if (userId === viewerId) { + // if removing a role, make sure someone else has elevated permissions + if (!roleToSet) { const leaders = orgUsers.filter( ({role}) => role && ['BILLING_LEADER', 'ORG_ADMIN'].includes(role) ) const leaderCount = leaders.length - if (leaderCount === 1 && !roleToSet) { - return standardError(new Error('You’re the last leader, you can’t give that up'), { + if (leaderCount === 1) { + return standardError(new Error('Cannot remove permissions of the last leader'), { userId: viewerId }) } diff --git a/packages/server/graphql/public/subscriptions/organizationSubscription.ts b/packages/server/graphql/public/subscriptions/organizationSubscription.ts index aaaf5e37a87..3c6cff708e3 100644 --- a/packages/server/graphql/public/subscriptions/organizationSubscription.ts +++ b/packages/server/graphql/public/subscriptions/organizationSubscription.ts @@ -1,21 +1,28 @@ import {SubscriptionChannel} from 'parabol-client/types/constEnums' +import getRethink from '../../../database/rethinkDriver' import {getUserId} from '../../../utils/authorization' import getPubSub from '../../../utils/getPubSub' +import {SubscriptionContext} from '../../graphql' import {SubscriptionResolvers} from '../resolverTypes' -const organizationSubscription: SubscriptionResolvers['organizationSubscription'] = { - subscribe: async (_source, _args, {authToken, dataLoader}) => { - // AUTH - const viewerId = getUserId(authToken) +const organizationSubscription: SubscriptionResolvers['organizationSubscription'] = + { + subscribe: async (_source, _args, {authToken}) => { + // AUTH + const viewerId = getUserId(authToken) + const r = await getRethink() + const organizationUsers = await r + .table('OrganizationUser') + .getAll(viewerId, {index: 'userId'}) + .filter({removedAt: null}) + .run() + const orgIds = organizationUsers.map(({orgId}) => orgId) - const organizationUsers = await dataLoader.get('organizationUsersByUserId').load(viewerId) - const orgIds = organizationUsers.map(({orgId}) => orgId) - - // RESOLUTION - const channelNames = orgIds - .concat(viewerId) - .map((id) => `${SubscriptionChannel.ORGANIZATION}.${id}`) - return getPubSub().subscribe(channelNames) + // RESOLUTION + const channelNames = orgIds + .concat(viewerId) + .map((id) => `${SubscriptionChannel.ORGANIZATION}.${id}`) + return getPubSub().subscribe(channelNames) + } } -} export default organizationSubscription From 4fd2139259aee13106bdb69cbd7a786664cacaf7 Mon Sep 17 00:00:00 2001 From: Matt Krick Date: Wed, 10 Jul 2024 17:36:24 -0700 Subject: [PATCH 44/47] remove RethinkDB.OrganizationUser Signed-off-by: Matt Krick --- codegen.json | 2 +- .../server/billing/helpers/adjustUserCount.ts | 41 ++++------------- .../server/billing/helpers/teamLimitsCheck.ts | 12 ----- .../helpers/updateSubscriptionQuantity.ts | 18 +------- packages/server/database/rethinkDriver.ts | 5 --- .../server/database/types/OrganizationUser.ts | 41 ----------------- .../__tests__/isOrgVerified.test.ts | 45 +------------------ .../server/dataloader/customLoaderMakers.ts | 41 +++++++++-------- .../dataloader/foreignKeyLoaderMakers.ts | 26 +++++++++++ .../dataloader/primaryKeyLoaderMakers.ts | 4 ++ .../rethinkForeignKeyLoaderMakers.ts | 26 ----------- .../rethinkPrimaryKeyLoaderMakers.ts | 1 - .../graphql/mutations/archiveOrganization.ts | 11 ----- .../graphql/mutations/helpers/createNewOrg.ts | 19 ++++---- .../mutations/helpers/removeFromOrg.ts | 21 +-------- .../graphql/mutations/oldUpgradeToTeamTier.ts | 8 ---- .../private/mutations/autopauseUsers.ts | 16 +------ .../mutations/draftEnterpriseInvoice.ts | 8 ---- .../private/mutations/hardDeleteUser.ts | 4 -- .../private/mutations/toggleAllowInsights.ts | 14 +----- .../private/mutations/upgradeToTeamTier.ts | 8 ---- .../graphql/private/queries/suOrgCount.ts | 27 ++--------- .../graphql/private/queries/suProOrgInfo.ts | 23 ++-------- .../public/mutations/setOrgUserRole.ts | 6 --- .../subscriptions/organizationSubscription.ts | 16 ++++--- .../public/types/SetOrgUserRoleSuccess.ts | 2 +- packages/server/graphql/types/User.ts | 13 +++--- .../insertStripeQuantityMismatchLogging.ts | 2 +- packages/server/postgres/types/index.d.ts | 3 ++ .../safeArchiveEmptyStarterOrganization.ts | 9 ---- .../isRequestToJoinDomainAllowed.test.ts | 44 +----------------- packages/server/utils/authorization.ts | 4 +- packages/server/utils/setTierForOrgUsers.ts | 11 ----- packages/server/utils/setUserTierForOrgId.ts | 11 +---- .../server/utils/setUserTierForUserIds.ts | 10 +---- 35 files changed, 110 insertions(+), 442 deletions(-) delete mode 100644 packages/server/database/types/OrganizationUser.ts create mode 100644 packages/server/postgres/types/index.d.ts diff --git a/codegen.json b/codegen.json index cfa97845070..b4f07f4e750 100644 --- a/codegen.json +++ b/codegen.json @@ -92,7 +92,7 @@ "NotifyTaskInvolves": "../../database/types/NotificationTaskInvolves#default", "NotifyTeamArchived": "../../database/types/NotificationTeamArchived#default", "Organization": "./types/Organization#OrganizationSource", - "OrganizationUser": "../../database/types/OrganizationUser#default as OrganizationUser", + "OrganizationUser": "../../postgres/types/index#OrganizationUser", "PokerMeeting": "../../database/types/MeetingPoker#default as MeetingPoker", "PokerMeetingMember": "../../database/types/MeetingPokerMeetingMember#default as PokerMeetingMemberDB", "PokerTemplate": "../../database/types/PokerTemplate#default as PokerTemplateDB", diff --git a/packages/server/billing/helpers/adjustUserCount.ts b/packages/server/billing/helpers/adjustUserCount.ts index 360a72df49d..97740203e5c 100644 --- a/packages/server/billing/helpers/adjustUserCount.ts +++ b/packages/server/billing/helpers/adjustUserCount.ts @@ -1,15 +1,12 @@ import {sql} from 'kysely' import {InvoiceItemType} from 'parabol-client/types/constEnums' -import getRethink from '../../database/rethinkDriver' -import {RDatum} from '../../database/stricterR' -import OrganizationUser from '../../database/types/OrganizationUser' +import generateUID from '../../generateUID' import {DataLoaderWorker} from '../../graphql/graphql' import isValid from '../../graphql/isValid' import getKysely from '../../postgres/getKysely' import insertOrgUserAudit from '../../postgres/helpers/insertOrgUserAudit' import {OrganizationUserAuditEventTypeEnum} from '../../postgres/queries/generated/insertOrgUserAuditQuery' import {getUserById} from '../../postgres/queries/getUsersByIds' -import updateUser from '../../postgres/queries/updateUser' import IUser from '../../postgres/types/IUser' import {Logger} from '../../utils/Logger' import {analytics} from '../../utils/analytics/analytics' @@ -45,7 +42,7 @@ const maybeUpdateOrganizationActiveDomain = async ( } const changePause = (inactive: boolean) => async (_orgIds: string[], user: IUser) => { - const r = await getRethink() + const pg = getKysely() const {id: userId, email} = user inactive ? analytics.accountPaused(user) : analytics.accountUnpaused(user) analytics.identify({ @@ -54,30 +51,18 @@ const changePause = (inactive: boolean) => async (_orgIds: string[], user: IUser isActive: !inactive }) await Promise.all([ - updateUser( - { - inactive - }, - userId - ), - getKysely() + pg.updateTable('User').set({inactive}).where('id', '=', userId).execute(), + pg .updateTable('OrganizationUser') .set({inactive}) .where('userId', '=', userId) .where('removedAt', 'is', null) - .execute(), - r - .table('OrganizationUser') - .getAll(userId, {index: 'userId'}) - .filter({removedAt: null}) - .update({inactive}) - .run() + .execute() ]) } const addUser = async (orgIds: string[], user: IUser, dataLoader: DataLoaderWorker) => { const {id: userId} = user - const r = await getRethink() const [rawOrganizations, organizationUsers] = await Promise.all([ dataLoader.get('organizations').loadMany(orgIds), dataLoader.get('organizationUsersByUserId').load(userId) @@ -90,19 +75,18 @@ const addUser = async (orgIds: string[], user: IUser, dataLoader: DataLoaderWork ) const organization = organizations.find((organization) => organization.id === orgId)! // continue the grace period from before, if any OR set to the end of the invoice OR (if it is a free account) no grace period - return new OrganizationUser({ - id: oldOrganizationUser?.id, + return { + id: oldOrganizationUser?.id || generateUID(), orgId, userId, tier: organization.tier - }) + } }) await getKysely() .insertInto('OrganizationUser') .values(docs) .onConflict((oc) => oc.doNothing()) .execute() - await r.table('OrganizationUser').insert(docs, {conflict: 'replace'}).run() await Promise.all( orgIds.map((orgId) => { return maybeUpdateOrganizationActiveDomain(orgId, user.email, dataLoader) @@ -111,7 +95,6 @@ const addUser = async (orgIds: string[], user: IUser, dataLoader: DataLoaderWork } const deleteUser = async (orgIds: string[], user: IUser) => { - const r = await getRethink() orgIds.forEach((orgId) => analytics.userRemovedFromOrg(user, orgId)) await getKysely() .updateTable('OrganizationUser') @@ -119,14 +102,6 @@ const deleteUser = async (orgIds: string[], user: IUser) => { .where('userId', '=', user.id) .where('orgId', 'in', orgIds) .execute() - await r - .table('OrganizationUser') - .getAll(user.id, {index: 'userId'}) - .filter((row: RDatum) => r.expr(orgIds).contains(row('orgId'))) - .update({ - removedAt: new Date() - }) - .run() } const dbActionTypeLookup = { diff --git a/packages/server/billing/helpers/teamLimitsCheck.ts b/packages/server/billing/helpers/teamLimitsCheck.ts index 9d2b8d52944..849edb8420e 100644 --- a/packages/server/billing/helpers/teamLimitsCheck.ts +++ b/packages/server/billing/helpers/teamLimitsCheck.ts @@ -28,12 +28,6 @@ const enableUsageStats = async (userIds: string[], orgId: string) => { .where('userId', 'in', userIds) .where('removedAt', 'is', null) .execute() - await r - .table('OrganizationUser') - .getAll(r.args(userIds), {index: 'userId'}) - .filter({orgId}) - .update({suggestedTier: 'team'}) - .run() await pg .updateTable('User') .set({featureFlags: sql`arr_append_uniq("featureFlags", 'insights')`}) @@ -101,12 +95,6 @@ export const maybeRemoveRestrictions = async (orgId: string, dataLoader: DataLoa .where('userId', 'in', billingLeadersIds) .where('removedAt', 'is', null) .execute(), - r - .table('OrganizationUser') - .getAll(r.args(billingLeadersIds), {index: 'userId'}) - .filter({orgId}) - .update({suggestedTier: 'starter'}) - .run(), removeTeamsLimitObjects(orgId, dataLoader) ]) dataLoader.get('organizations').clear(orgId) diff --git a/packages/server/billing/helpers/updateSubscriptionQuantity.ts b/packages/server/billing/helpers/updateSubscriptionQuantity.ts index b6b2634eb39..79db4e1de5e 100644 --- a/packages/server/billing/helpers/updateSubscriptionQuantity.ts +++ b/packages/server/billing/helpers/updateSubscriptionQuantity.ts @@ -1,4 +1,3 @@ -import getRethink from '../../database/rethinkDriver' import getKysely from '../../postgres/getKysely' import insertStripeQuantityMismatchLogging from '../../postgres/queries/insertStripeQuantityMismatchLogging' import RedisLockQueue from '../../utils/RedisLockQueue' @@ -10,7 +9,6 @@ import {getStripeManager} from '../../utils/stripe' * @param logMismatch Pass true if a quantity mismatch should be logged */ const updateSubscriptionQuantity = async (orgId: string, logMismatch?: boolean) => { - const r = await getRethink() const pg = getKysely() const manager = getStripeManager() @@ -35,7 +33,7 @@ const updateSubscriptionQuantity = async (orgId: string, logMismatch?: boolean) return } - const [orgUserCountRes, orgUserCount, teamSubscription] = await Promise.all([ + const [orgUserCountRes, teamSubscription] = await Promise.all([ pg .selectFrom('OrganizationUser') .select(({fn}) => fn.count('id').as('count')) @@ -43,21 +41,9 @@ const updateSubscriptionQuantity = async (orgId: string, logMismatch?: boolean) .where('removedAt', 'is', null) .where('inactive', '=', false) .executeTakeFirstOrThrow(), - r - .table('OrganizationUser') - .getAll(orgId, {index: 'orgId'}) - .filter({removedAt: null, inactive: false}) - .count() - .run(), manager.getSubscriptionItem(stripeSubscriptionId) ]) - if (orgUserCountRes.count !== orgUserCount) { - sendToSentry(new Error('OrganizationUser count mismatch'), { - tags: { - orgId - } - }) - } + const {count: orgUserCount} = orgUserCountRes if ( teamSubscription && teamSubscription.quantity !== undefined && diff --git a/packages/server/database/rethinkDriver.ts b/packages/server/database/rethinkDriver.ts index 3eb79efcc06..fdada8558b9 100644 --- a/packages/server/database/rethinkDriver.ts +++ b/packages/server/database/rethinkDriver.ts @@ -25,7 +25,6 @@ import NotificationResponseReplied from './types/NotificationResponseReplied' import NotificationTaskInvolves from './types/NotificationTaskInvolves' import NotificationTeamArchived from './types/NotificationTeamArchived' import NotificationTeamInvitation from './types/NotificationTeamInvitation' -import OrganizationUser from './types/OrganizationUser' import PasswordResetRequest from './types/PasswordResetRequest' import PushInvitation from './types/PushInvitation' import RetrospectivePrompt from './types/RetrospectivePrompt' @@ -112,10 +111,6 @@ export type RethinkSchema = { | NotificationMentioned index: 'userId' } - OrganizationUser: { - type: OrganizationUser - index: 'orgId' | 'userId' - } PasswordResetRequest: { type: PasswordResetRequest index: 'email' | 'ip' | 'token' diff --git a/packages/server/database/types/OrganizationUser.ts b/packages/server/database/types/OrganizationUser.ts deleted file mode 100644 index 927242b2dc6..00000000000 --- a/packages/server/database/types/OrganizationUser.ts +++ /dev/null @@ -1,41 +0,0 @@ -import generateUID from '../../generateUID' -import {TierEnum} from './Invoice' - -export type OrgUserRole = 'BILLING_LEADER' | 'ORG_ADMIN' -interface Input { - orgId: string - userId: string - id?: string - inactive?: boolean - joinedAt?: Date - removedAt?: Date - role?: OrgUserRole - tier?: TierEnum - suggestedTier?: TierEnum -} - -export default class OrganizationUser { - id: string - suggestedTier: TierEnum | null - inactive: boolean - joinedAt: Date - orgId: string - removedAt: Date | null - role: OrgUserRole | null - userId: string - tier: TierEnum - trialStartDate?: Date | null - - constructor(input: Input) { - const {suggestedTier, userId, id, removedAt, inactive, orgId, joinedAt, role, tier} = input - this.id = id || generateUID() - this.suggestedTier = suggestedTier || null - this.inactive = inactive || false - this.joinedAt = joinedAt || new Date() - this.orgId = orgId - this.removedAt = removedAt || null - this.role = role || null - this.userId = userId - this.tier = tier || 'starter' - } -} diff --git a/packages/server/dataloader/__tests__/isOrgVerified.test.ts b/packages/server/dataloader/__tests__/isOrgVerified.test.ts index 6fa0c0c1b0e..a1b1cde3e36 100644 --- a/packages/server/dataloader/__tests__/isOrgVerified.test.ts +++ b/packages/server/dataloader/__tests__/isOrgVerified.test.ts @@ -1,56 +1,26 @@ /* eslint-env jest */ import {Insertable} from 'kysely' -import {r} from 'rethinkdb-ts' import {createPGTables, truncatePGTables} from '../../__tests__/common' -import getRethinkConfig from '../../database/getRethinkConfig' -import getRethink from '../../database/rethinkDriver' -import OrganizationUser from '../../database/types/OrganizationUser' import generateUID from '../../generateUID' import getKysely from '../../postgres/getKysely' import {User} from '../../postgres/pg' +import {OrganizationUser} from '../../postgres/types' import getRedis from '../../utils/getRedis' import isUserVerified from '../../utils/isUserVerified' import RootDataLoader from '../RootDataLoader' -jest.mock('../../database/rethinkDriver') jest.mock('../../utils/isUserVerified') -jest.mocked(getRethink).mockImplementation(() => { - return r as any -}) - jest.mocked(isUserVerified).mockImplementation(() => { return true }) const TEST_DB = 'getVerifiedOrgIdsTest' -const config = getRethinkConfig() -const testConfig = { - ...config, - db: TEST_DB -} - type TestUser = Insertable const addUsers = async (users: TestUser[]) => { return getKysely().insertInto('User').values(users).execute() } -const createTables = async (...tables: string[]) => { - for (const tableName of tables) { - const structure = await r - .db('rethinkdb') - .table('table_config') - .filter({db: config.db, name: tableName}) - .run() - await r.tableCreate(tableName).run() - const {indexes} = structure[0] - for (const index of indexes) { - await r.table(tableName).indexCreate(index).run() - } - await r.table(tableName).indexWait().run() - } -} - type TestOrganizationUser = Partial< Pick > @@ -90,33 +60,20 @@ const addOrg = async ( await pg.insertInto('Organization').values(org).execute() } - await r.table('OrganizationUser').insert(orgUsers).run() return orgId } beforeAll(async () => { - await r.connectPool(testConfig) const pg = getKysely(TEST_DB) - - try { - await r.dbDrop(TEST_DB).run() - } catch (e) { - //ignore - } await pg.schema.createSchema(TEST_DB).ifNotExists().execute() - - await r.dbCreate(TEST_DB).run() await createPGTables('Organization', 'User', 'SAML', 'SAMLDomain', 'OrganizationUser') - await createTables('OrganizationUser') }) afterEach(async () => { await truncatePGTables('Organization', 'User') - await r.table('OrganizationUser').delete().run() }) afterAll(async () => { - await r.getPoolMaster()?.drain() await getKysely().destroy() getRedis().quit() }) diff --git a/packages/server/dataloader/customLoaderMakers.ts b/packages/server/dataloader/customLoaderMakers.ts index 46a03715026..ec2429d1326 100644 --- a/packages/server/dataloader/customLoaderMakers.ts +++ b/packages/server/dataloader/customLoaderMakers.ts @@ -6,7 +6,6 @@ import getRethink, {RethinkSchema} from '../database/rethinkDriver' import {RDatum} from '../database/stricterR' import MeetingSettingsTeamPrompt from '../database/types/MeetingSettingsTeamPrompt' import MeetingTemplate from '../database/types/MeetingTemplate' -import OrganizationUser from '../database/types/OrganizationUser' import {Reactable, ReactableEnum} from '../database/types/Reactable' import Task, {TaskStatusEnum} from '../database/types/Task' import getFileStoreManager from '../fileStorage/getFileStoreManager' @@ -29,6 +28,7 @@ import getLatestTaskEstimates from '../postgres/queries/getLatestTaskEstimates' import getMeetingTaskEstimates, { MeetingTaskEstimatesResult } from '../postgres/queries/getMeetingTaskEstimates' +import {OrganizationUser} from '../postgres/types' import {AnyMeeting, MeetingTypeEnum} from '../postgres/types/Meeting' import {Logger} from '../utils/Logger' import getRedis from '../utils/getRedis' @@ -393,18 +393,20 @@ export const organizationApprovedDomains = (parent: RootDataLoader) => { export const organizationUsersByUserIdOrgId = (parent: RootDataLoader) => { return new DataLoader<{orgId: string; userId: string}, OrganizationUser | null, string>( async (keys) => { - const r = await getRethink() + const pg = getKysely() return Promise.all( - keys.map((key) => { + keys.map(async (key) => { const {userId, orgId} = key if (!userId || !orgId) return null - return r - .table('OrganizationUser') - .getAll(userId, {index: 'userId'}) - .filter({orgId, removedAt: null}) - .nth(0) - .default(null) - .run() + const res = await pg + .selectFrom('OrganizationUser') + .selectAll() + .where('userId', '=', userId) + .where('orgId', '=', orgId) + .where('removedAt', 'is', null) + .limit(1) + .executeTakeFirst() + return res || null }) ) }, @@ -656,16 +658,17 @@ export const lastMeetingByMeetingSeriesId = (parent: RootDataLoader) => { export const billingLeadersIdsByOrgId = (parent: RootDataLoader) => { return new DataLoader( async (keys) => { - const r = await getRethink() + const pg = getKysely() const res = await Promise.all( - keys.map((orgId) => { - return r - .table('OrganizationUser') - .getAll(orgId, {index: 'orgId'}) - .filter({removedAt: null}) - .filter((row: RDatum) => r.expr(['BILLING_LEADER', 'ORG_ADMIN']).contains(row('role'))) - .coerceTo('array')('userId') - .run() + keys.map(async (orgId) => { + const rows = await pg + .selectFrom('OrganizationUser') + .select('userId') + .where('orgId', '=', orgId) + .where('removedAt', 'is', null) + .where('role', 'in', ['BILLING_LEADER', 'ORG_ADMIN']) + .execute() + return rows.map((row) => row.userId) }) ) return res diff --git a/packages/server/dataloader/foreignKeyLoaderMakers.ts b/packages/server/dataloader/foreignKeyLoaderMakers.ts index 543647daedb..2f6cb7daf09 100644 --- a/packages/server/dataloader/foreignKeyLoaderMakers.ts +++ b/packages/server/dataloader/foreignKeyLoaderMakers.ts @@ -81,3 +81,29 @@ export const organizationsByActiveDomain = foreignKeyLoaderMaker( return selectOrganizations().where('activeDomain', 'in', activeDomains).execute() } ) + +export const organizationUsersByUserId = foreignKeyLoaderMaker( + 'organizationUsers', + 'userId', + async (userIds) => { + return getKysely() + .selectFrom('OrganizationUser') + .selectAll() + .where('userId', 'in', userIds) + .where('removedAt', 'is', null) + .execute() + } +) + +export const organizationUsersByOrgId = foreignKeyLoaderMaker( + 'organizationUsers', + 'orgId', + async (orgIds) => { + return getKysely() + .selectFrom('OrganizationUser') + .selectAll() + .where('orgId', 'in', orgIds) + .where('removedAt', 'is', null) + .execute() + } +) diff --git a/packages/server/dataloader/primaryKeyLoaderMakers.ts b/packages/server/dataloader/primaryKeyLoaderMakers.ts index dd10a3f0d75..48f9907c636 100644 --- a/packages/server/dataloader/primaryKeyLoaderMakers.ts +++ b/packages/server/dataloader/primaryKeyLoaderMakers.ts @@ -131,3 +131,7 @@ export const selectOrganizations = () => export const organizations = primaryKeyLoaderMaker((ids: readonly string[]) => { return selectOrganizations().where('id', 'in', ids).execute() }) + +export const organizationUsers = primaryKeyLoaderMaker((ids: readonly string[]) => { + return getKysely().selectFrom('OrganizationUser').selectAll().where('id', 'in', ids).execute() +}) diff --git a/packages/server/dataloader/rethinkForeignKeyLoaderMakers.ts b/packages/server/dataloader/rethinkForeignKeyLoaderMakers.ts index 4385bb32fd9..913c7dcc87f 100644 --- a/packages/server/dataloader/rethinkForeignKeyLoaderMakers.ts +++ b/packages/server/dataloader/rethinkForeignKeyLoaderMakers.ts @@ -116,32 +116,6 @@ export const meetingMembersByUserId = new RethinkForeignKeyLoaderMaker( } ) -export const organizationUsersByOrgId = new RethinkForeignKeyLoaderMaker( - 'organizationUsers', - 'orgId', - async (orgIds) => { - const r = await getRethink() - return r - .table('OrganizationUser') - .getAll(r.args(orgIds), {index: 'orgId'}) - .filter({removedAt: null}) - .run() - } -) - -export const organizationUsersByUserId = new RethinkForeignKeyLoaderMaker( - 'organizationUsers', - 'userId', - async (userIds) => { - const r = await getRethink() - return r - .table('OrganizationUser') - .getAll(r.args(userIds), {index: 'userId'}) - .filter({removedAt: null}) - .run() - } -) - export const scalesByTeamId = new RethinkForeignKeyLoaderMaker( 'templateScales', 'teamId', diff --git a/packages/server/dataloader/rethinkPrimaryKeyLoaderMakers.ts b/packages/server/dataloader/rethinkPrimaryKeyLoaderMakers.ts index 78a198aa9b9..ef78ee05b50 100644 --- a/packages/server/dataloader/rethinkPrimaryKeyLoaderMakers.ts +++ b/packages/server/dataloader/rethinkPrimaryKeyLoaderMakers.ts @@ -13,7 +13,6 @@ export const meetingMembers = new RethinkPrimaryKeyLoaderMaker('MeetingMember') export const newMeetings = new RethinkPrimaryKeyLoaderMaker('NewMeeting') export const newFeatures = new RethinkPrimaryKeyLoaderMaker('NewFeature') export const notifications = new RethinkPrimaryKeyLoaderMaker('Notification') -export const organizationUsers = new RethinkPrimaryKeyLoaderMaker('OrganizationUser') export const templateScales = new RethinkPrimaryKeyLoaderMaker('TemplateScale') export const slackAuths = new RethinkPrimaryKeyLoaderMaker('SlackAuth') export const slackNotifications = new RethinkPrimaryKeyLoaderMaker('SlackNotification') diff --git a/packages/server/graphql/mutations/archiveOrganization.ts b/packages/server/graphql/mutations/archiveOrganization.ts index cbe6942d08b..7d8235c51f8 100644 --- a/packages/server/graphql/mutations/archiveOrganization.ts +++ b/packages/server/graphql/mutations/archiveOrganization.ts @@ -2,7 +2,6 @@ import {GraphQLID, GraphQLNonNull} from 'graphql' import {sql} from 'kysely' import {SubscriptionChannel} from 'parabol-client/types/constEnums' import removeTeamsLimitObjects from '../../billing/helpers/removeTeamsLimitObjects' -import getRethink from '../../database/rethinkDriver' import Team from '../../database/types/Team' import User from '../../database/types/User' import getKysely from '../../postgres/getKysely' @@ -29,10 +28,8 @@ export default { {orgId}: {orgId: string}, {authToken, dataLoader, socketId: mutatorId}: GQLContext ) { - const r = await getRethink() const operationId = dataLoader.share() const subOptions = {operationId, mutatorId} - const now = new Date() // AUTH const viewerId = getUserId(authToken) @@ -89,14 +86,6 @@ export default { .where('orgId', '=', orgId) .where('removedAt', 'is', null) .execute(), - r - .table('OrganizationUser') - .getAll(orgId, {index: 'orgId'}) - .filter({removedAt: null}) - .update({ - removedAt: now - }) - .run(), removeTeamsLimitObjects(orgId, dataLoader) ]) diff --git a/packages/server/graphql/mutations/helpers/createNewOrg.ts b/packages/server/graphql/mutations/helpers/createNewOrg.ts index d4b069caee2..391fa04da43 100644 --- a/packages/server/graphql/mutations/helpers/createNewOrg.ts +++ b/packages/server/graphql/mutations/helpers/createNewOrg.ts @@ -1,6 +1,5 @@ -import getRethink from '../../../database/rethinkDriver' import Organization from '../../../database/types/Organization' -import OrganizationUser from '../../../database/types/OrganizationUser' +import generateUID from '../../../generateUID' import getKysely from '../../../postgres/getKysely' import insertOrgUserAudit from '../../../postgres/helpers/insertOrgUserAudit' import getDomainFromEmail from '../../../utils/getDomainFromEmail' @@ -13,7 +12,6 @@ export default async function createNewOrg( leaderEmail: string, dataLoader: DataLoaderWorker ) { - const r = await getRethink() const userDomain = getDomainFromEmail(leaderEmail) const isCompanyDomain = await dataLoader.get('isCompanyDomain').load(userDomain) const activeDomain = isCompanyDomain ? userDomain : undefined @@ -22,17 +20,16 @@ export default async function createNewOrg( name: orgName, activeDomain }) - const orgUser = new OrganizationUser({ - orgId, - userId: leaderUserId, - role: 'BILLING_LEADER', - tier: org.tier - }) await insertOrgUserAudit([orgId], leaderUserId, 'added') await getKysely() .with('Org', (qc) => qc.insertInto('Organization').values({...org, creditCard: null})) .insertInto('OrganizationUser') - .values(orgUser) + .values({ + id: generateUID(), + orgId, + userId: leaderUserId, + role: 'BILLING_LEADER', + tier: org.tier + }) .execute() - await r.table('OrganizationUser').insert(orgUser).run() } diff --git a/packages/server/graphql/mutations/helpers/removeFromOrg.ts b/packages/server/graphql/mutations/helpers/removeFromOrg.ts index dc6f2ad9934..ede6aba2610 100644 --- a/packages/server/graphql/mutations/helpers/removeFromOrg.ts +++ b/packages/server/graphql/mutations/helpers/removeFromOrg.ts @@ -2,7 +2,6 @@ import {sql} from 'kysely' import {InvoiceItemType} from 'parabol-client/types/constEnums' import adjustUserCount from '../../../billing/helpers/adjustUserCount' import getRethink from '../../../database/rethinkDriver' -import OrganizationUser from '../../../database/types/OrganizationUser' import getKysely from '../../../postgres/getKysely' import getTeamsByOrgIds from '../../../postgres/queries/getTeamsByOrgIds' import {Logger} from '../../../utils/Logger' @@ -19,7 +18,6 @@ const removeFromOrg = async ( ) => { const r = await getRethink() const pg = getKysely() - const now = new Date() const orgTeams = await getTeamsByOrgIds([orgId]) const teamIds = orgTeams.map((team) => team.id) const teamMemberIds = (await r @@ -44,23 +42,15 @@ const removeFromOrg = async ( return arr }, []) - const [_pgOrgUser, organizationUser, user] = await Promise.all([ + const [organizationUser, user] = await Promise.all([ pg .updateTable('OrganizationUser') .set({removedAt: sql`CURRENT_TIMESTAMP`}) .where('userId', '=', userId) .where('orgId', '=', orgId) .where('removedAt', 'is', null) - .returning('role') + .returning(['id', 'role']) .executeTakeFirstOrThrow(), - r - .table('OrganizationUser') - .getAll(userId, {index: 'userId'}) - .filter({orgId, removedAt: null}) - .nth(0) - .update({removedAt: now}, {returnChanges: true})('changes')(0)('new_val') - .default(null) - .run() as unknown as OrganizationUser, dataLoader.get('users').loadNonNull(userId) ]) @@ -83,13 +73,6 @@ const removeFromOrg = async ( .set({role: 'BILLING_LEADER'}) .where('id', '=', nextInLine.id) .execute() - await r - .table('OrganizationUser') - .get(nextInLine.id) - .update({ - role: 'BILLING_LEADER' - }) - .run() } else if (organization.tier !== 'starter') { await resolveDowngradeToStarter(orgId, organization.stripeSubscriptionId!, user, dataLoader) } diff --git a/packages/server/graphql/mutations/oldUpgradeToTeamTier.ts b/packages/server/graphql/mutations/oldUpgradeToTeamTier.ts index 7bf2e98adec..f4e0cafb5c1 100644 --- a/packages/server/graphql/mutations/oldUpgradeToTeamTier.ts +++ b/packages/server/graphql/mutations/oldUpgradeToTeamTier.ts @@ -1,6 +1,5 @@ import {GraphQLID, GraphQLNonNull} from 'graphql' import {SubscriptionChannel} from 'parabol-client/types/constEnums' -import getRethink from '../../database/rethinkDriver' import getKysely from '../../postgres/getKysely' import {analytics} from '../../utils/analytics/analytics' import {getUserId} from '../../utils/authorization' @@ -31,7 +30,6 @@ export default { {orgId, stripeToken}: {orgId: string; stripeToken: string}, {authToken, dataLoader, socketId: mutatorId}: GQLContext ) { - const r = await getRethink() const operationId = dataLoader.share() const subOptions = {mutatorId, operationId} @@ -73,12 +71,6 @@ export default { .where('orgId', '=', orgId) .where('removedAt', 'is', null) .execute() - await r - .table('OrganizationUser') - .getAll(viewerId, {index: 'userId'}) - .filter({removedAt: null, orgId}) - .update({role: 'BILLING_LEADER'}) - .run() const teams = await dataLoader.get('teamsByOrgIds').load(orgId) const teamIds = teams.map(({id}) => id) diff --git a/packages/server/graphql/private/mutations/autopauseUsers.ts b/packages/server/graphql/private/mutations/autopauseUsers.ts index 741f96be9af..f0df40b0375 100644 --- a/packages/server/graphql/private/mutations/autopauseUsers.ts +++ b/packages/server/graphql/private/mutations/autopauseUsers.ts @@ -1,6 +1,5 @@ import {InvoiceItemType, Threshold} from 'parabol-client/types/constEnums' import adjustUserCount from '../../../billing/helpers/adjustUserCount' -import getRethink from '../../../database/rethinkDriver' import getKysely from '../../../postgres/getKysely' import getUserIdsToPause from '../../../postgres/queries/getUserIdsToPause' import {Logger} from '../../../utils/Logger' @@ -11,7 +10,6 @@ const autopauseUsers: MutationResolvers['autopauseUsers'] = async ( _args, {dataLoader} ) => { - const r = await getRethink() const pg = getKysely() // RESOLUTION const activeThresh = new Date(Date.now() - Threshold.AUTO_PAUSE) @@ -22,7 +20,7 @@ const autopauseUsers: MutationResolvers['autopauseUsers'] = async ( const skip = i * BATCH_SIZE const userIdBatch = userIdsToPause.slice(skip, skip + BATCH_SIZE) if (userIdBatch.length < 1) break - const pgResults = await pg + const results = await pg .selectFrom('OrganizationUser') .select(({fn}) => ['userId', fn.agg('array_agg', ['orgId']).as('orgIds')]) .where('userId', 'in', userIdBatch) @@ -30,18 +28,8 @@ const autopauseUsers: MutationResolvers['autopauseUsers'] = async ( .groupBy('userId') .execute() - // TEST in Phase 2! - console.log(pgResults) - - const results = (await ( - r - .table('OrganizationUser') - .getAll(r.args(userIdBatch), {index: 'userId'}) - .filter({removedAt: null}) - .group('userId') as any - )('orgId').run()) as {group: string; reduction: string[]}[] await Promise.allSettled( - results.map(async ({group: userId, reduction: orgIds}) => { + results.map(async ({userId, orgIds}) => { try { return await adjustUserCount(userId, orgIds, InvoiceItemType.AUTO_PAUSE_USER, dataLoader) } catch (e) { diff --git a/packages/server/graphql/private/mutations/draftEnterpriseInvoice.ts b/packages/server/graphql/private/mutations/draftEnterpriseInvoice.ts index e07c5fadcec..58747bb4825 100644 --- a/packages/server/graphql/private/mutations/draftEnterpriseInvoice.ts +++ b/packages/server/graphql/private/mutations/draftEnterpriseInvoice.ts @@ -1,5 +1,4 @@ import removeTeamsLimitObjects from '../../../billing/helpers/removeTeamsLimitObjects' -import getRethink from '../../../database/rethinkDriver' import getKysely from '../../../postgres/getKysely' import {getUserByEmail} from '../../../postgres/queries/getUsersByEmails' import IUser from '../../../postgres/types/IUser' @@ -18,7 +17,6 @@ const getBillingLeaderUser = async ( orgId: string, dataLoader: DataLoaderWorker ) => { - const r = await getRethink() const pg = getKysely() if (email) { const user = await getUserByEmail(email) @@ -40,12 +38,6 @@ const getBillingLeaderUser = async ( .where('orgId', '=', orgId) .where('removedAt', 'is', null) .execute() - await r - .table('OrganizationUser') - .getAll(userId, {index: 'userId'}) - .filter({removedAt: null, orgId}) - .update({role: 'BILLING_LEADER'}) - .run() } return user } diff --git a/packages/server/graphql/private/mutations/hardDeleteUser.ts b/packages/server/graphql/private/mutations/hardDeleteUser.ts index 22d3084aba1..80a28568d12 100644 --- a/packages/server/graphql/private/mutations/hardDeleteUser.ts +++ b/packages/server/graphql/private/mutations/hardDeleteUser.ts @@ -107,10 +107,6 @@ const hardDeleteUser: MutationResolvers['hardDeleteUser'] = async ( teamMember: r.table('TeamMember').getAll(userIdToDelete, {index: 'userId'}).delete(), meetingMember: r.table('MeetingMember').getAll(userIdToDelete, {index: 'userId'}).delete(), notification: r.table('Notification').getAll(userIdToDelete, {index: 'userId'}).delete(), - organizationUser: r - .table('OrganizationUser') - .getAll(userIdToDelete, {index: 'userId'}) - .delete(), suggestedAction: r.table('SuggestedAction').getAll(userIdToDelete, {index: 'userId'}).delete(), createdTasks: r .table('Task') diff --git a/packages/server/graphql/private/mutations/toggleAllowInsights.ts b/packages/server/graphql/private/mutations/toggleAllowInsights.ts index 0d4a6d80cb4..a9346d7f865 100644 --- a/packages/server/graphql/private/mutations/toggleAllowInsights.ts +++ b/packages/server/graphql/private/mutations/toggleAllowInsights.ts @@ -1,4 +1,3 @@ -import {r, RValue} from 'rethinkdb-ts' import getKysely from '../../../postgres/getKysely' import {getUsersByEmails} from '../../../postgres/queries/getUsersByEmails' import {MutationResolvers} from '../resolverTypes' @@ -26,7 +25,7 @@ const toggleAllowInsights: MutationResolvers['toggleAllowInsights'] = async ( const userIds = users.map(({id}) => id) const recordsReplaced = await Promise.all( userIds.map(async (userId) => { - await pg + return await pg .updateTable('OrganizationUser') .set({suggestedTier}) .where('userId', '=', userId) @@ -34,18 +33,9 @@ const toggleAllowInsights: MutationResolvers['toggleAllowInsights'] = async ( .where('removedAt', 'is', null) .returning('id') .execute() - return r - .table('OrganizationUser') - .getAll(r.args(orgIds), {index: 'orgId'}) - .filter({ - userId - }) - .filter((row: RValue) => row('removedAt').default(null).eq(null)) - .update({suggestedTier})('replaced') - .run() }) ) - return {organizationUsersAffected: recordsReplaced.reduce((x, y) => x + y)} + return {organizationUsersAffected: recordsReplaced.flat().length} } export default toggleAllowInsights diff --git a/packages/server/graphql/private/mutations/upgradeToTeamTier.ts b/packages/server/graphql/private/mutations/upgradeToTeamTier.ts index e051a0ccb81..5530633f8a2 100644 --- a/packages/server/graphql/private/mutations/upgradeToTeamTier.ts +++ b/packages/server/graphql/private/mutations/upgradeToTeamTier.ts @@ -1,6 +1,5 @@ import {SubscriptionChannel} from 'parabol-client/types/constEnums' import removeTeamsLimitObjects from '../../../billing/helpers/removeTeamsLimitObjects' -import getRethink from '../../../database/rethinkDriver' import getKysely from '../../../postgres/getKysely' import {toCreditCard} from '../../../postgres/helpers/toCreditCard' import {analytics} from '../../../utils/analytics/analytics' @@ -41,7 +40,6 @@ const upgradeToTeamTier: MutationResolvers['upgradeToTeamTier'] = async ( return standardError(new Error('Customer does not have an orgId'), {userId}) } - const r = await getRethink() const pg = getKysely() const operationId = dataLoader.share() const subOptions = {mutatorId, operationId} @@ -107,12 +105,6 @@ const upgradeToTeamTier: MutationResolvers['upgradeToTeamTier'] = async ( .where('orgId', '=', orgId) .where('removedAt', 'is', null) .execute() - await r - .table('OrganizationUser') - .getAll(viewerId, {index: 'userId'}) - .filter({removedAt: null, orgId}) - .update({role: 'BILLING_LEADER'}) - .run() const teams = await dataLoader.get('teamsByOrgIds').load(orgId) const teamIds = teams.map(({id}) => id) diff --git a/packages/server/graphql/private/queries/suOrgCount.ts b/packages/server/graphql/private/queries/suOrgCount.ts index b2dd6f6afc3..52632b14d4e 100644 --- a/packages/server/graphql/private/queries/suOrgCount.ts +++ b/packages/server/graphql/private/queries/suOrgCount.ts @@ -1,11 +1,9 @@ -import getRethink from '../../../database/rethinkDriver' -import {RValue} from '../../../database/stricterR' import getKysely from '../../../postgres/getKysely' import {QueryResolvers} from '../resolverTypes' const suOrgCount: QueryResolvers['suOrgCount'] = async (_source, {minOrgSize, tier}) => { const pg = getKysely() - const pgResults = await pg + const result = await pg .with('BigOrgs', (qb) => qb .selectFrom('OrganizationUser') @@ -17,28 +15,9 @@ const suOrgCount: QueryResolvers['suOrgCount'] = async (_source, {minOrgSize, ti .having(({eb, fn}) => eb(fn.count('id'), '>=', minOrgSize)) ) .selectFrom('BigOrgs') - .select(({fn}) => fn.count('orgSize').as('count')) + .select(({fn}) => fn.count('orgSize').as('count')) .executeTakeFirstOrThrow() - - // TEST in Phase 2! - console.log(pgResults) - - const r = await getRethink() - return ( - r - .table('OrganizationUser') - .getAll( - [tier, false] as unknown as string, // super hacky type fix bc no fn overload is defined in the type file for this valid invocation - {index: 'tierInactive'} as unknown as undefined - ) - .filter({removedAt: null}) - .group('orgId') as any - ) - .count() - .ungroup() - .filter((group: RValue) => group('reduction').ge(minOrgSize)) - .count() - .run() + return result.count } export default suOrgCount diff --git a/packages/server/graphql/private/queries/suProOrgInfo.ts b/packages/server/graphql/private/queries/suProOrgInfo.ts index 3e604f7a5bb..c842ce85e5b 100644 --- a/packages/server/graphql/private/queries/suProOrgInfo.ts +++ b/packages/server/graphql/private/queries/suProOrgInfo.ts @@ -1,40 +1,25 @@ -import {sql} from 'kysely' -import getRethink from '../../../database/rethinkDriver' -import {RDatum} from '../../../database/stricterR' import {selectOrganizations} from '../../../dataloader/primaryKeyLoaderMakers' import getKysely from '../../../postgres/getKysely' import {QueryResolvers} from '../resolverTypes' const suProOrgInfo: QueryResolvers['suProOrgInfo'] = async (_source, {includeInactive}) => { - const r = await getRethink() const pg = getKysely() const proOrgs = await selectOrganizations().where('tier', '=', 'team').execute() - if (includeInactive) return proOrgs + if (includeInactive || proOrgs.length === 0) return proOrgs const proOrgIds = proOrgs.map(({id}) => id) const pgResults = await pg .selectFrom('OrganizationUser') - .select(({fn}) => fn.count('id').as('orgSize')) + .select(['orgId', ({fn}) => fn.count('id').as('orgSize')]) // use ANY to support case where proOrgIds is empty array. Please use `in` after RethinkDB is gone - .where('orgId', '=', sql`ANY(${proOrgIds})`) + .where('orgId', 'in', proOrgIds) .where('inactive', '=', false) .where('removedAt', 'is', null) .groupBy('orgId') .having(({eb, fn}) => eb(fn.count('id'), '>=', 1)) .execute() - const activeOrgIds = await ( - r - .table('OrganizationUser') - .getAll(r.args(proOrgIds), {index: 'orgId'}) - .filter({removedAt: null, inactive: false}) - .group('orgId') as RDatum - ) - .count() - .ungroup() - .filter((row: RDatum) => row('reduction').ge(1))('group') - .run() - console.log({pgResults, activeOrgIds}) + const activeOrgIds = pgResults.map(({orgId}) => orgId) return proOrgs.filter((org) => activeOrgIds.includes(org.id)) } diff --git a/packages/server/graphql/public/mutations/setOrgUserRole.ts b/packages/server/graphql/public/mutations/setOrgUserRole.ts index d00522221fe..64f378c95b6 100644 --- a/packages/server/graphql/public/mutations/setOrgUserRole.ts +++ b/packages/server/graphql/public/mutations/setOrgUserRole.ts @@ -21,7 +21,6 @@ const setOrgUserRole: MutationResolvers['setOrgUserRole'] = async ( {orgId, userId, role: roleToSet}, {authToken, dataLoader, socketId: mutatorId} ) => { - const r = await getRethink() const pg = getKysely() const operationId = dataLoader.share() const subOptions = {mutatorId, operationId} @@ -90,11 +89,6 @@ const setOrgUserRole: MutationResolvers['setOrgUserRole'] = async ( .set({role: roleToSet || null}) .where('id', '=', organizationUserId) .execute() - await r - .table('OrganizationUser') - .get(organizationUserId) - .update({role: roleToSet || null}) - .run() organizationUser.role = roleToSet || null if (roleToSet !== 'ORG_ADMIN') { const modificationType = roleToSet === 'BILLING_LEADER' ? 'add' : 'remove' diff --git a/packages/server/graphql/public/subscriptions/organizationSubscription.ts b/packages/server/graphql/public/subscriptions/organizationSubscription.ts index 3c6cff708e3..545bbd1e101 100644 --- a/packages/server/graphql/public/subscriptions/organizationSubscription.ts +++ b/packages/server/graphql/public/subscriptions/organizationSubscription.ts @@ -1,5 +1,5 @@ import {SubscriptionChannel} from 'parabol-client/types/constEnums' -import getRethink from '../../../database/rethinkDriver' +import getKysely from '../../../postgres/getKysely' import {getUserId} from '../../../utils/authorization' import getPubSub from '../../../utils/getPubSub' import {SubscriptionContext} from '../../graphql' @@ -10,12 +10,14 @@ const organizationSubscription: SubscriptionResolvers['orga subscribe: async (_source, _args, {authToken}) => { // AUTH const viewerId = getUserId(authToken) - const r = await getRethink() - const organizationUsers = await r - .table('OrganizationUser') - .getAll(viewerId, {index: 'userId'}) - .filter({removedAt: null}) - .run() + const pg = getKysely() + + const organizationUsers = await pg + .selectFrom('OrganizationUser') + .select('orgId') + .where('userId', '=', viewerId) + .where('removedAt', 'is', null) + .execute() const orgIds = organizationUsers.map(({orgId}) => orgId) // RESOLUTION diff --git a/packages/server/graphql/public/types/SetOrgUserRoleSuccess.ts b/packages/server/graphql/public/types/SetOrgUserRoleSuccess.ts index 9d70f951f12..3954347afdf 100644 --- a/packages/server/graphql/public/types/SetOrgUserRoleSuccess.ts +++ b/packages/server/graphql/public/types/SetOrgUserRoleSuccess.ts @@ -13,7 +13,7 @@ const SetOrgUserRoleSuccess: SetOrgUserRoleSuccessResolvers = { return dataLoader.get('organizations').loadNonNull(orgId) }, updatedOrgMember: async ({organizationUserId}, _args, {dataLoader}) => { - return dataLoader.get('organizationUsers').load(organizationUserId) + return dataLoader.get('organizationUsers').loadNonNull(organizationUserId) }, notificationsAdded: async ({notificationIdsAdded}, _args, {authToken, dataLoader}) => { if (!notificationIdsAdded.length) return [] diff --git a/packages/server/graphql/types/User.ts b/packages/server/graphql/types/User.ts index 0054e59fa36..292a6eab123 100644 --- a/packages/server/graphql/types/User.ts +++ b/packages/server/graphql/types/User.ts @@ -16,7 +16,6 @@ import { } from '../../../client/utils/constants' import groupReflections from '../../../client/utils/smartGroup/groupReflections' import MeetingMemberType from '../../database/types/MeetingMember' -import OrganizationUserType from '../../database/types/OrganizationUser' import SuggestedActionType from '../../database/types/SuggestedAction' import getKysely from '../../postgres/getKysely' import {getUserId, isSuperUser, isTeamMember} from '../../utils/authorization' @@ -83,7 +82,7 @@ const User: GraphQLObjectType = new GraphQLObjectType { const organizationUsers = await dataLoader.get('organizationUsersByUserId').load(userId) return organizationUsers.some( - (organizationUser: OrganizationUserType) => + (organizationUser) => organizationUser.role === 'BILLING_LEADER' || organizationUser.role === 'ORG_ADMIN' ) } @@ -379,17 +378,15 @@ const User: GraphQLObjectType = new GraphQLObjectType { const viewerId = getUserId(authToken) const organizationUsers = await dataLoader.get('organizationUsersByUserId').load(userId) - organizationUsers.sort((a: OrganizationUserType, b: OrganizationUserType) => - a.orgId > b.orgId ? 1 : -1 - ) + organizationUsers.sort((a, b) => (a.orgId > b.orgId ? 1 : -1)) if (viewerId === userId || isSuperUser(authToken)) { return organizationUsers } const viewerOrganizationUsers = await dataLoader .get('organizationUsersByUserId') .load(viewerId) - const viewerOrgIds = viewerOrganizationUsers.map(({orgId}: OrganizationUserType) => orgId) - return organizationUsers.filter((organizationUser: OrganizationUserType) => + const viewerOrgIds = viewerOrganizationUsers.map(({orgId}) => orgId) + return organizationUsers.filter((organizationUser) => viewerOrgIds.includes(organizationUser.orgId) ) } @@ -430,7 +427,7 @@ const User: GraphQLObjectType = new GraphQLObjectType { const organizationUsers = await dataLoader.get('organizationUsersByUserId').load(userId) const isAnyMemberOfPaidOrg = organizationUsers.some( - (organizationUser: OrganizationUserType) => organizationUser.tier !== 'starter' + (organizationUser) => organizationUser.tier !== 'starter' ) if (isAnyMemberOfPaidOrg) return null return overLimitCopy diff --git a/packages/server/postgres/queries/insertStripeQuantityMismatchLogging.ts b/packages/server/postgres/queries/insertStripeQuantityMismatchLogging.ts index 5e75fd08dd9..e2ca6c263dd 100644 --- a/packages/server/postgres/queries/insertStripeQuantityMismatchLogging.ts +++ b/packages/server/postgres/queries/insertStripeQuantityMismatchLogging.ts @@ -1,5 +1,5 @@ -import OrganizationUser from '../../database/types/OrganizationUser' import getPg from '../getPg' +import {OrganizationUser} from '../types' import {insertStripeQuantityMismatchLoggingQuery} from './generated/insertStripeQuantityMismatchLoggingQuery' const insertStripeQuantityMismatchLogging = async ( diff --git a/packages/server/postgres/types/index.d.ts b/packages/server/postgres/types/index.d.ts new file mode 100644 index 00000000000..cdc9adef2ad --- /dev/null +++ b/packages/server/postgres/types/index.d.ts @@ -0,0 +1,3 @@ +import {Selectable} from 'kysely' +import {OrganizationUser as OrganizationUserPG} from '../pg.d' +export type OrganizationUser = Selectable diff --git a/packages/server/safeMutations/safeArchiveEmptyStarterOrganization.ts b/packages/server/safeMutations/safeArchiveEmptyStarterOrganization.ts index c5ddd92aec5..8dd9c8221e6 100644 --- a/packages/server/safeMutations/safeArchiveEmptyStarterOrganization.ts +++ b/packages/server/safeMutations/safeArchiveEmptyStarterOrganization.ts @@ -1,5 +1,4 @@ import {sql} from 'kysely' -import getRethink from '../database/rethinkDriver' import {DataLoaderInstance} from '../dataloader/RootDataLoader' import getKysely from '../postgres/getKysely' import getTeamsByOrgIds from '../postgres/queries/getTeamsByOrgIds' @@ -11,9 +10,7 @@ const safeArchiveEmptyStarterOrganization = async ( orgId: string, dataLoader: DataLoaderInstance ) => { - const r = await getRethink() const pg = getKysely() - const now = new Date() const orgTeams = await getTeamsByOrgIds([orgId]) const teamCountRemainingOnOldOrg = orgTeams.length @@ -26,12 +23,6 @@ const safeArchiveEmptyStarterOrganization = async ( .where('orgId', '=', orgId) .where('removedAt', 'is', null) .execute() - await r - .table('OrganizationUser') - .getAll(orgId, {index: 'orgId'}) - .filter({removedAt: null}) - .update({removedAt: now}) - .run() } export default safeArchiveEmptyStarterOrganization diff --git a/packages/server/utils/__tests__/isRequestToJoinDomainAllowed.test.ts b/packages/server/utils/__tests__/isRequestToJoinDomainAllowed.test.ts index a17af53e9aa..52b67b82600 100644 --- a/packages/server/utils/__tests__/isRequestToJoinDomainAllowed.test.ts +++ b/packages/server/utils/__tests__/isRequestToJoinDomainAllowed.test.ts @@ -1,48 +1,17 @@ /* eslint-env jest */ import {Insertable} from 'kysely' -import {r} from 'rethinkdb-ts' import {createPGTables, truncatePGTables} from '../../__tests__/common' -import getRethinkConfig from '../../database/getRethinkConfig' -import getRethink from '../../database/rethinkDriver' import {TierEnum} from '../../database/types/Invoice' -import OrganizationUser from '../../database/types/OrganizationUser' import RootDataLoader from '../../dataloader/RootDataLoader' import generateUID from '../../generateUID' import getKysely from '../../postgres/getKysely' import {User} from '../../postgres/pg' +import {OrganizationUser} from '../../postgres/types' import getRedis from '../getRedis' import {getEligibleOrgIdsByDomain} from '../isRequestToJoinDomainAllowed' -jest.mock('../../database/rethinkDriver') - -jest.mocked(getRethink).mockImplementation(() => { - return r as any -}) - const TEST_DB = 'isRequestToJoinDomainAllowedTest' -const config = getRethinkConfig() -const testConfig = { - ...config, - db: TEST_DB -} - -const createTables = async (...tables: string[]) => { - for (const tableName of tables) { - const structure = await r - .db('rethinkdb') - .table('table_config') - .filter({db: config.db, name: tableName}) - .run() - await r.tableCreate(tableName).run() - const {indexes} = structure[0] - for (const index of indexes) { - await r.table(tableName).indexCreate(index).run() - } - await r.table(tableName).indexWait().run() - } -} - type TestOrganizationUser = Partial< Pick > & {userId: string} @@ -80,20 +49,12 @@ const addOrg = async ( .insertInto('OrganizationUser') .values(orgUsers) .execute() - await r.table('OrganizationUser').insert(orgUsers).run() return orgId } beforeAll(async () => { - await r.connectPool(testConfig) const pg = getKysely(TEST_DB) - try { - await r.dbDrop(TEST_DB).run() - } catch (e) { - //ignore - } await pg.schema.createSchema(TEST_DB).ifNotExists().execute() - await r.dbCreate(TEST_DB).run() await createPGTables( 'Organization', 'User', @@ -102,16 +63,13 @@ beforeAll(async () => { 'SAMLDomain', 'OrganizationUser' ) - await createTables('OrganizationUser') }) afterEach(async () => { await truncatePGTables('Organization', 'User', 'OrganizationUser') - await r.table('OrganizationUser').delete().run() }) afterAll(async () => { - await r.getPoolMaster()?.drain() await getKysely().destroy() getRedis().quit() }) diff --git a/packages/server/utils/authorization.ts b/packages/server/utils/authorization.ts index b2c0f0b17f6..8617c3bab16 100644 --- a/packages/server/utils/authorization.ts +++ b/packages/server/utils/authorization.ts @@ -1,8 +1,8 @@ import toTeamMemberId from 'parabol-client/utils/relay/toTeamMemberId' import getRethink from '../database/rethinkDriver' import AuthToken from '../database/types/AuthToken' -import {OrgUserRole} from '../database/types/OrganizationUser' import {DataLoaderWorker} from '../graphql/graphql' +import {OrganizationUser} from '../postgres/types' export const getUserId = (authToken: any) => { return authToken && typeof authToken === 'object' ? (authToken.sub as string) : '' @@ -55,7 +55,7 @@ const isUserAnyRoleIn = async ( userId: string, orgId: string, dataLoader: DataLoaderWorker, - roles: OrgUserRole[], + roles: NonNullable[], options?: Options ) => { const organizationUser = await dataLoader diff --git a/packages/server/utils/setTierForOrgUsers.ts b/packages/server/utils/setTierForOrgUsers.ts index 8131dd85f1b..28a68ce96f7 100644 --- a/packages/server/utils/setTierForOrgUsers.ts +++ b/packages/server/utils/setTierForOrgUsers.ts @@ -7,11 +7,9 @@ * and rejoins the same org, a new `OrganizationUser` row * will be created. */ -import getRethink from '../database/rethinkDriver' import getKysely from '../postgres/getKysely' const setTierForOrgUsers = async (orgId: string) => { - const r = await getRethink() const pg = getKysely() const organization = await getKysely() .selectFrom('Organization') @@ -25,15 +23,6 @@ const setTierForOrgUsers = async (orgId: string) => { .where('orgId', '=', orgId) .where('removedAt', 'is', null) .execute() - await r - .table('OrganizationUser') - .getAll(orgId, {index: 'orgId'}) - .filter({removedAt: null}) - .update({ - tier, - trialStartDate - }) - .run() } export default setTierForOrgUsers diff --git a/packages/server/utils/setUserTierForOrgId.ts b/packages/server/utils/setUserTierForOrgId.ts index 23e1480070a..5c494855b83 100644 --- a/packages/server/utils/setUserTierForOrgId.ts +++ b/packages/server/utils/setUserTierForOrgId.ts @@ -1,22 +1,15 @@ -import getRethink from '../database/rethinkDriver' import getKysely from '../postgres/getKysely' import setUserTierForUserIds from './setUserTierForUserIds' const setUserTierForOrgId = async (orgId: string) => { - const r = await getRethink() const pg = getKysely() - const _userIds = await pg + const orgUsers = await pg .selectFrom('OrganizationUser') .select('userId') .where('orgId', '=', orgId) .where('removedAt', 'is', null) .execute() - console.log({_userIds}) - const userIds = await r - .table('OrganizationUser') - .getAll(orgId, {index: 'orgId'}) - .filter({removedAt: null})('userId') - .run() + const userIds = orgUsers.map(({userId}) => userId) await setUserTierForUserIds(userIds) } diff --git a/packages/server/utils/setUserTierForUserIds.ts b/packages/server/utils/setUserTierForUserIds.ts index 2e1c9dae552..28bdc75962d 100644 --- a/packages/server/utils/setUserTierForUserIds.ts +++ b/packages/server/utils/setUserTierForUserIds.ts @@ -1,4 +1,3 @@ -import getRethink from '../database/rethinkDriver' import isValid from '../graphql/isValid' import getKysely from '../postgres/getKysely' import {analytics} from './analytics/analytics' @@ -10,20 +9,13 @@ import {analytics} from './analytics/analytics' // 'setTierForOrgUsers'. const setUserTierForUserId = async (userId: string) => { - const r = await getRethink() const pg = getKysely() - const _orgUsers = await pg + const orgUsers = await pg .selectFrom('OrganizationUser') .selectAll() .where('userId', '=', userId) .where('removedAt', 'is', null) .execute() - console.log({_orgUsers}) - const orgUsers = await r - .table('OrganizationUser') - .getAll(userId, {index: 'userId'}) - .filter({removedAt: null}) - .run() const orgIds = orgUsers.map((orgUser) => orgUser.orgId) if (orgIds.length === 0) return From ef0b5499e25549ec50a61b39da14a727c5a721ba Mon Sep 17 00:00:00 2001 From: Matt Krick Date: Thu, 11 Jul 2024 13:44:17 -0700 Subject: [PATCH 45/47] fix tests Signed-off-by: Matt Krick --- .../server/billing/helpers/adjustUserCount.ts | 16 +++++------ .../__tests__/isOrgVerified.test.ts | 8 +----- .../server/dataloader/customLoaderMakers.ts | 28 +++++++++---------- 3 files changed, 22 insertions(+), 30 deletions(-) diff --git a/packages/server/billing/helpers/adjustUserCount.ts b/packages/server/billing/helpers/adjustUserCount.ts index 97740203e5c..dec7219d6a6 100644 --- a/packages/server/billing/helpers/adjustUserCount.ts +++ b/packages/server/billing/helpers/adjustUserCount.ts @@ -50,15 +50,13 @@ const changePause = (inactive: boolean) => async (_orgIds: string[], user: IUser email, isActive: !inactive }) - await Promise.all([ - pg.updateTable('User').set({inactive}).where('id', '=', userId).execute(), - pg - .updateTable('OrganizationUser') - .set({inactive}) - .where('userId', '=', userId) - .where('removedAt', 'is', null) - .execute() - ]) + await pg + .with('User', (qb) => qb.updateTable('User').set({inactive}).where('id', '=', userId)) + .updateTable('OrganizationUser') + .set({inactive}) + .where('userId', '=', userId) + .where('removedAt', 'is', null) + .execute() } const addUser = async (orgIds: string[], user: IUser, dataLoader: DataLoaderWorker) => { diff --git a/packages/server/dataloader/__tests__/isOrgVerified.test.ts b/packages/server/dataloader/__tests__/isOrgVerified.test.ts index a1b1cde3e36..1d9c056fc2e 100644 --- a/packages/server/dataloader/__tests__/isOrgVerified.test.ts +++ b/packages/server/dataloader/__tests__/isOrgVerified.test.ts @@ -6,13 +6,7 @@ import getKysely from '../../postgres/getKysely' import {User} from '../../postgres/pg' import {OrganizationUser} from '../../postgres/types' import getRedis from '../../utils/getRedis' -import isUserVerified from '../../utils/isUserVerified' import RootDataLoader from '../RootDataLoader' -jest.mock('../../utils/isUserVerified') - -jest.mocked(isUserVerified).mockImplementation(() => { - return true -}) const TEST_DB = 'getVerifiedOrgIdsTest' @@ -70,7 +64,7 @@ beforeAll(async () => { }) afterEach(async () => { - await truncatePGTables('Organization', 'User') + await truncatePGTables('Organization', 'User', 'OrganizationUser') }) afterAll(async () => { diff --git a/packages/server/dataloader/customLoaderMakers.ts b/packages/server/dataloader/customLoaderMakers.ts index ec2429d1326..24e1b6c31b6 100644 --- a/packages/server/dataloader/customLoaderMakers.ts +++ b/packages/server/dataloader/customLoaderMakers.ts @@ -9,7 +9,6 @@ import MeetingTemplate from '../database/types/MeetingTemplate' import {Reactable, ReactableEnum} from '../database/types/Reactable' import Task, {TaskStatusEnum} from '../database/types/Task' import getFileStoreManager from '../fileStorage/getFileStoreManager' -import isValid from '../graphql/isValid' import {SAMLSource} from '../graphql/public/types/SAML' import {TeamSource} from '../graphql/public/types/Team' import getKysely from '../postgres/getKysely' @@ -743,21 +742,22 @@ export const samlByOrgId = (parent: RootDataLoader) => { export const isOrgVerified = (parent: RootDataLoader) => { return new DataLoader( async (orgIds) => { - const orgUsersRes = await parent.get('organizationUsersByOrgId').loadMany(orgIds) - const orgUsersWithRole = orgUsersRes - .filter(isValid) - .flat() - .filter(({role}) => role && ['BILLING_LEADER', 'ORG_ADMIN'].includes(role)) - const orgUsersUserIds = orgUsersWithRole.map((orgUser) => orgUser.userId) - const usersRes = await parent.get('users').loadMany(orgUsersUserIds) - const verifiedUsers = usersRes.filter(isValid).filter(isUserVerified) - const verifiedOrgUsers = orgUsersWithRole.filter((orgUser) => - verifiedUsers.some((user) => user.id === orgUser.userId) - ) return await Promise.all( orgIds.map(async (orgId) => { - const isUserVerified = verifiedOrgUsers.some((orgUser) => orgUser.orgId === orgId) - if (isUserVerified) return true + const [organization, orgUsers] = await Promise.all([ + parent.get('organizations').loadNonNull(orgId), + parent.get('organizationUsersByOrgId').load(orgId) + ]) + const orgLeaders = orgUsers.filter( + ({role}) => role && ['BILLING_LEADER', 'ORG_ADMIN'].includes(role) + ) + const orgLeaderUsers = await Promise.all( + orgLeaders.map(({userId}) => parent.get('users').loadNonNull(userId)) + ) + const isALeaderVerifiedAtOrgDomain = orgLeaderUsers.some( + (user) => isUserVerified(user) && user.domain === organization.activeDomain + ) + if (isALeaderVerifiedAtOrgDomain) return true const isOrgSAML = await parent.get('samlByOrgId').load(orgId) return !!isOrgSAML }) From 8a1b2e06c18a246b1065d55836a049f8002542e7 Mon Sep 17 00:00:00 2001 From: Matt Krick Date: Mon, 15 Jul 2024 10:27:31 -0700 Subject: [PATCH 46/47] fix: quit DB in jest --- packages/server/__tests__/globalTeardown.ts | 6 +++++- packages/server/dataloader/__tests__/isOrgVerified.test.ts | 6 ------ packages/server/utils/__tests__/RedisLockQueue.test.ts | 5 ----- 3 files changed, 5 insertions(+), 12 deletions(-) diff --git a/packages/server/__tests__/globalTeardown.ts b/packages/server/__tests__/globalTeardown.ts index 6b98f0e346c..b5a3cd1633f 100644 --- a/packages/server/__tests__/globalTeardown.ts +++ b/packages/server/__tests__/globalTeardown.ts @@ -1,8 +1,12 @@ import getRethink from '../database/rethinkDriver' +import getKysely from '../postgres/getKysely' +import getRedis from '../utils/getRedis' async function teardown() { const r = await getRethink() - return r.getPoolMaster()?.drain() + await r.getPoolMaster()?.drain() + await getKysely().destroy() + await getRedis().quit() } export default teardown diff --git a/packages/server/dataloader/__tests__/isOrgVerified.test.ts b/packages/server/dataloader/__tests__/isOrgVerified.test.ts index 1d9c056fc2e..42f0deb410b 100644 --- a/packages/server/dataloader/__tests__/isOrgVerified.test.ts +++ b/packages/server/dataloader/__tests__/isOrgVerified.test.ts @@ -5,7 +5,6 @@ import generateUID from '../../generateUID' import getKysely from '../../postgres/getKysely' import {User} from '../../postgres/pg' import {OrganizationUser} from '../../postgres/types' -import getRedis from '../../utils/getRedis' import RootDataLoader from '../RootDataLoader' const TEST_DB = 'getVerifiedOrgIdsTest' @@ -67,11 +66,6 @@ afterEach(async () => { await truncatePGTables('Organization', 'User', 'OrganizationUser') }) -afterAll(async () => { - await getKysely().destroy() - getRedis().quit() -}) - test('Founder is billing lead', async () => { await addUsers([ { diff --git a/packages/server/utils/__tests__/RedisLockQueue.test.ts b/packages/server/utils/__tests__/RedisLockQueue.test.ts index bde386f290c..4cf78c09a1d 100644 --- a/packages/server/utils/__tests__/RedisLockQueue.test.ts +++ b/packages/server/utils/__tests__/RedisLockQueue.test.ts @@ -1,11 +1,6 @@ /* eslint-env jest */ import sleep from 'parabol-client/utils/sleep' import RedisLockQueue from '../RedisLockQueue' -import getRedis from '../getRedis' - -afterAll(async () => { - getRedis().quit() -}) test('lock calls are queued properly', async () => { await Promise.all( From e8b960ba9ff116001d13a82b6582ba2e6ddfe1bd Mon Sep 17 00:00:00 2001 From: Matt Krick Date: Mon, 15 Jul 2024 10:59:27 -0700 Subject: [PATCH 47/47] fix: quit DB in jest Signed-off-by: Matt Krick --- packages/server/dataloader/__tests__/isOrgVerified.test.ts | 4 ++++ packages/server/utils/__tests__/RedisLockQueue.test.ts | 5 +++++ 2 files changed, 9 insertions(+) diff --git a/packages/server/dataloader/__tests__/isOrgVerified.test.ts b/packages/server/dataloader/__tests__/isOrgVerified.test.ts index 42f0deb410b..ed37f906642 100644 --- a/packages/server/dataloader/__tests__/isOrgVerified.test.ts +++ b/packages/server/dataloader/__tests__/isOrgVerified.test.ts @@ -66,6 +66,10 @@ afterEach(async () => { await truncatePGTables('Organization', 'User', 'OrganizationUser') }) +afterAll(async () => { + await getKysely().destroy() +}) + test('Founder is billing lead', async () => { await addUsers([ { diff --git a/packages/server/utils/__tests__/RedisLockQueue.test.ts b/packages/server/utils/__tests__/RedisLockQueue.test.ts index 4cf78c09a1d..1dc0deeb044 100644 --- a/packages/server/utils/__tests__/RedisLockQueue.test.ts +++ b/packages/server/utils/__tests__/RedisLockQueue.test.ts @@ -1,6 +1,11 @@ /* eslint-env jest */ import sleep from 'parabol-client/utils/sleep' import RedisLockQueue from '../RedisLockQueue' +import getRedis from '../getRedis' + +afterAll(async () => { + await getRedis().quit() +}) test('lock calls are queued properly', async () => { await Promise.all(