diff --git a/packages/server/dataloader/foreignKeyLoaderMakers.ts b/packages/server/dataloader/foreignKeyLoaderMakers.ts index bcbda0ddeb4..b77d19e6fa0 100644 --- a/packages/server/dataloader/foreignKeyLoaderMakers.ts +++ b/packages/server/dataloader/foreignKeyLoaderMakers.ts @@ -1,5 +1,10 @@ import getKysely from '../postgres/getKysely' -import {selectTemplateDimension, selectTemplateScale, selectTimelineEvent} from '../postgres/select' +import { + selectSuggestedAction, + selectTemplateDimension, + selectTemplateScale, + selectTimelineEvent +} from '../postgres/select' import {foreignKeyLoaderMaker} from './foreignKeyLoaderMaker' import {selectOrganizations, selectRetroReflections, selectTeams} from './primaryKeyLoaderMakers' @@ -148,3 +153,11 @@ export const templateDimensionsByScaleId = foreignKeyLoaderMaker( return selectTemplateDimension().where('scaleId', 'in', scaleIds).orderBy('sortOrder').execute() } ) + +export const _suggestedActionsByUserId = foreignKeyLoaderMaker( + '_suggestedActions', + 'userId', + async (userIds) => { + return selectSuggestedAction().where('userId', 'in', userIds).execute() + } +) diff --git a/packages/server/dataloader/primaryKeyLoaderMakers.ts b/packages/server/dataloader/primaryKeyLoaderMakers.ts index eb876d09798..19724a66df5 100644 --- a/packages/server/dataloader/primaryKeyLoaderMakers.ts +++ b/packages/server/dataloader/primaryKeyLoaderMakers.ts @@ -7,6 +7,7 @@ import {getTeamPromptResponsesByIds} from '../postgres/queries/getTeamPromptResp import getTemplateRefsByIds from '../postgres/queries/getTemplateRefsByIds' import {getUsersByIds} from '../postgres/queries/getUsersByIds' import { + selectSuggestedAction, selectTemplateDimension, selectTemplateScale, selectTemplateScaleRef, @@ -165,3 +166,7 @@ export const templateScales = primaryKeyLoaderMaker((ids: readonly string[]) => export const templateDimensions = primaryKeyLoaderMaker((ids: readonly string[]) => { return selectTemplateDimension().where('id', 'in', ids).execute() }) + +export const _suggestedActions = primaryKeyLoaderMaker((ids: readonly string[]) => { + return selectSuggestedAction().where('id', 'in', ids).execute() +}) diff --git a/packages/server/dataloader/rethinkForeignKeyLoaderMakers.ts b/packages/server/dataloader/rethinkForeignKeyLoaderMakers.ts index a0da54c416f..8251f871542 100644 --- a/packages/server/dataloader/rethinkForeignKeyLoaderMakers.ts +++ b/packages/server/dataloader/rethinkForeignKeyLoaderMakers.ts @@ -142,7 +142,7 @@ export const suggestedActionsByUserId = new RethinkForeignKeyLoaderMaker( return r .table('SuggestedAction') .getAll(r.args(userIds), {index: 'userId'}) - .filter({removedAt: null}) + .filter((row: any) => row('removedAt').default(null).eq(null)) .run() } ) diff --git a/packages/server/graphql/mutations/dismissSuggestedAction.ts b/packages/server/graphql/mutations/dismissSuggestedAction.ts index 22a91062387..5c34d8ccf6c 100644 --- a/packages/server/graphql/mutations/dismissSuggestedAction.ts +++ b/packages/server/graphql/mutations/dismissSuggestedAction.ts @@ -1,5 +1,6 @@ import {GraphQLID, GraphQLNonNull} from 'graphql' import getRethink from '../../database/rethinkDriver' +import getKysely from '../../postgres/getKysely' import {getUserId} from '../../utils/authorization' import standardError from '../../utils/standardError' import {GQLContext} from '../graphql' @@ -34,6 +35,11 @@ export default { } // RESOLUTION + await getKysely() + .updateTable('SuggestedAction') + .set({removedAt: now}) + .where('id', '=', suggestedActionId) + .execute() await r.table('SuggestedAction').get(suggestedActionId).update({removedAt: now}).run() // no need to publish since that'll only affect their other open tabs diff --git a/packages/server/graphql/mutations/helpers/bootstrapNewUser.ts b/packages/server/graphql/mutations/helpers/bootstrapNewUser.ts index 8d1d09d3cbd..8e855486ad2 100644 --- a/packages/server/graphql/mutations/helpers/bootstrapNewUser.ts +++ b/packages/server/graphql/mutations/helpers/bootstrapNewUser.ts @@ -1,8 +1,5 @@ import getRethink from '../../../database/rethinkDriver' import AuthToken from '../../../database/types/AuthToken' -import SuggestedActionCreateNewTeam from '../../../database/types/SuggestedActionCreateNewTeam' -import SuggestedActionInviteYourTeam from '../../../database/types/SuggestedActionInviteYourTeam' -import SuggestedActionTryTheDemo from '../../../database/types/SuggestedActionTryTheDemo' import TimelineEventJoinedParabol from '../../../database/types/TimelineEventJoinedParabol' import User from '../../../database/types/User' import generateUID from '../../../generateUID' @@ -89,28 +86,43 @@ const bootstrapNewUser = async ( const teamsWithAutoJoin = teamsWithAutoJoinRes.flat().filter(isValid) const tms = [] as string[] - + const actions = [ + { + id: generateUID(), + userId, + type: 'tryTheDemo' as const, + priority: 1 + }, + { + id: generateUID(), + userId, + type: 'createNewTeam' as const, + priority: 4 + } + ] if (teamsWithAutoJoin.length > 0) { await Promise.all( teamsWithAutoJoin.map((team) => { const teamId = team.id tms.push(teamId) + const inviteYourTeam = { + id: generateUID(), + userId, + teamId, + type: 'inviteYourTeam' as const, + priority: 2 + } return Promise.all([ acceptTeamInvitation(team, userId, dataLoader), isOrganic ? Promise.all([ - r - .table('SuggestedAction') - .insert(new SuggestedActionInviteYourTeam({userId, teamId})) - .run() + pg.insertInto('SuggestedAction').values(inviteYourTeam).execute(), + r.table('SuggestedAction').insert(inviteYourTeam).run() ]) - : r - .table('SuggestedAction') - .insert([ - new SuggestedActionTryTheDemo({userId}), - new SuggestedActionCreateNewTeam({userId}) - ]) - .run(), + : Promise.all([ + pg.insertInto('SuggestedAction').values(actions).execute(), + r.table('SuggestedAction').insert(actions).run() + ]), analytics.autoJoined(newUser, teamId) ]) }) @@ -130,15 +142,12 @@ const bootstrapNewUser = async ( await Promise.all([ createTeamAndLeader(newUser as IUser, validNewTeam, dataLoader), addSeedTasks(userId, teamId), - r.table('SuggestedAction').insert(new SuggestedActionInviteYourTeam({userId, teamId})).run(), sendPromptToJoinOrg(newUser, dataLoader) ]) analytics.newOrg(newUser, orgId, teamId, true) } else { - await r - .table('SuggestedAction') - .insert([new SuggestedActionTryTheDemo({userId}), new SuggestedActionCreateNewTeam({userId})]) - .run() + await pg.insertInto('SuggestedAction').values(actions).execute() + await r.table('SuggestedAction').insert(actions).run() } analytics.accountCreated(newUser, !isOrganic, isPatient0) diff --git a/packages/server/graphql/mutations/helpers/createTeamAndLeader.ts b/packages/server/graphql/mutations/helpers/createTeamAndLeader.ts index 92ca62973de..9da9169e175 100644 --- a/packages/server/graphql/mutations/helpers/createTeamAndLeader.ts +++ b/packages/server/graphql/mutations/helpers/createTeamAndLeader.ts @@ -7,6 +7,7 @@ import MeetingSettingsRetrospective from '../../../database/types/MeetingSetting import Team from '../../../database/types/Team' import TimelineEventCreatedTeam from '../../../database/types/TimelineEventCreatedTeam' import {DataLoaderInstance} from '../../../dataloader/RootDataLoader' +import generateUID from '../../../generateUID' import getKysely from '../../../postgres/getKysely' import IUser from '../../../postgres/types/IUser' @@ -42,6 +43,13 @@ export default async function createTeamAndLeader( }) const pg = getKysely() + const suggestedAction = { + id: generateUID(), + userId, + teamId, + type: 'inviteYourTeam' as const, + priority: 2 + } await Promise.all([ pg .with('TeamInsert', (qc) => qc.insertInto('Team').values(verifiedTeam)) @@ -63,11 +71,27 @@ export default async function createTeamAndLeader( openDrawer: 'manageTeam' }) ) + .with('SuggestedActionInsert', (qc) => + qc + .insertInto('SuggestedAction') + .values(suggestedAction) + .onConflict((oc) => oc.columns(['userId', 'type']).doNothing()) + ) .insertInto('TimelineEvent') .values(timelineEvent) .execute(), // add meeting settings r.table('MeetingSettings').insert(meetingSettings).run() ]) + const hasSuggestedAction = await r + .table('SuggestedAction') + .getAll(userId, {index: 'userId'}) + .filter({type: 'inviteYourTeam'}) + .count() + .ge(1) + .run() + if (!hasSuggestedAction) { + await r.table('SuggestedAction').insert(suggestedAction).run() + } dataLoader.clearAll(['teams', 'users', 'teamMembers', 'timelineEvents', 'meetingSettings']) } diff --git a/packages/server/graphql/private/mutations/hardDeleteUser.ts b/packages/server/graphql/private/mutations/hardDeleteUser.ts index 9037ff5a653..7bf8932ca67 100644 --- a/packages/server/graphql/private/mutations/hardDeleteUser.ts +++ b/packages/server/graphql/private/mutations/hardDeleteUser.ts @@ -89,7 +89,6 @@ const hardDeleteUser: MutationResolvers['hardDeleteUser'] = async ( .run(), meetingMember: r.table('MeetingMember').getAll(userIdToDelete, {index: 'userId'}).delete(), notification: r.table('Notification').getAll(userIdToDelete, {index: 'userId'}).delete(), - suggestedAction: r.table('SuggestedAction').getAll(userIdToDelete, {index: 'userId'}).delete(), createdTasks: r .table('Task') .getAll(r.args(teamIds), {index: 'teamId'}) diff --git a/packages/server/postgres/migrations/1721940319404_SuggestedAction-phase1.ts b/packages/server/postgres/migrations/1721940319404_SuggestedAction-phase1.ts new file mode 100644 index 00000000000..61f99780e50 --- /dev/null +++ b/packages/server/postgres/migrations/1721940319404_SuggestedAction-phase1.ts @@ -0,0 +1,52 @@ +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 = 'SuggestedActionTypeEnum') THEN + CREATE TYPE "SuggestedActionTypeEnum" AS ENUM ( + 'inviteYourTeam', + 'tryTheDemo', + 'createNewTeam', + 'tryRetroMeeting', + 'tryActionMeeting' + ); + END IF; + CREATE TABLE IF NOT EXISTS "SuggestedAction" ( + "id" VARCHAR(100) PRIMARY KEY, + "createdAt" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(), + "priority" SMALLINT NOT NULL DEFAULT 0, + "removedAt" TIMESTAMP WITH TIME ZONE, + "type" "SuggestedActionTypeEnum" NOT NULL, + "teamId" VARCHAR(100), + "userId" VARCHAR(100) NOT NULL, + UNIQUE("userId", "type"), + 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_SuggestedAction_teamId" ON "SuggestedAction"("teamId"); + CREATE INDEX IF NOT EXISTS "idx_SuggestedAction_userId" ON "SuggestedAction"("userId"); + END $$; +`) + await client.end() +} + +export async function down() { + const client = new Client(getPgConfig()) + await client.connect() + await client.query(` + DROP TABLE "SuggestedAction"; + DROP TYPE "SuggestedActionTypeEnum"; + ` /* Do undo magic */) + await client.end() +} diff --git a/packages/server/postgres/select.ts b/packages/server/postgres/select.ts index 9fad53dac29..f369d0b7e51 100644 --- a/packages/server/postgres/select.ts +++ b/packages/server/postgres/select.ts @@ -44,3 +44,7 @@ export const selectTemplateScale = () => { export const selectTemplateDimension = () => { return getKysely().selectFrom('TemplateDimension').selectAll().where('removedAt', 'is', null) } + +export const selectSuggestedAction = () => { + return getKysely().selectFrom('SuggestedAction').selectAll().where('removedAt', 'is', null) +} diff --git a/packages/server/safeMutations/acceptTeamInvitation.ts b/packages/server/safeMutations/acceptTeamInvitation.ts index 587a4b68fdf..c48cba5df64 100644 --- a/packages/server/safeMutations/acceptTeamInvitation.ts +++ b/packages/server/safeMutations/acceptTeamInvitation.ts @@ -3,7 +3,6 @@ import {InvoiceItemType} from 'parabol-client/types/constEnums' import TeamMemberId from '../../client/shared/gqlIds/TeamMemberId' import adjustUserCount from '../billing/helpers/adjustUserCount' import getRethink from '../database/rethinkDriver' -import SuggestedActionCreateNewTeam from '../database/types/SuggestedActionCreateNewTeam' import {DataLoaderInstance} from '../dataloader/RootDataLoader' import generateUID from '../generateUID' import {DataLoaderWorker} from '../graphql/graphql' @@ -23,38 +22,39 @@ const handleFirstAcceptedInvitation = async ( const teamMembers = await dataLoader.get('teamMembersByTeamId').load(teamId) const teamLead = teamMembers.find((tm) => tm.isLead)! const {userId} = teamLead - const isNewTeamLead = await r - .table('SuggestedAction') - .getAll(userId, {index: 'userId'}) - .filter({type: 'tryRetroMeeting'}) - .count() - .eq(0) - .run() - if (!isNewTeamLead) return null - await r - .table('SuggestedAction') - .insert([ - { - id: generateUID(), - createdAt: now, - priority: 3, - removedAt: null, - teamId, - type: 'tryRetroMeeting', - userId - }, - new SuggestedActionCreateNewTeam({userId}), - { - id: generateUID(), - createdAt: now, - priority: 5, - removedAt: null, - teamId, - type: 'tryActionMeeting', - userId - } - ]) - .run() + const suggestedActions = await dataLoader.get('suggestedActionsByUserId').load(userId) + const hasTryRetro = suggestedActions.some((sa) => sa.type === 'tryRetroMeeting') + if (hasTryRetro) return null + const actions = [ + { + id: generateUID(), + createdAt: now, + priority: 3, + removedAt: null, + teamId, + type: 'tryRetroMeeting' as const, + userId + }, + { + id: generateUID(), + createdAt: now, + priority: 4, + removedAt: null, + type: 'createNewTeam' as const, + userId + }, + { + id: generateUID(), + createdAt: now, + priority: 5, + removedAt: null, + teamId, + type: 'tryActionMeeting' as const, + userId + } + ] + await getKysely().insertInto('SuggestedAction').values(actions).execute() + await r.table('SuggestedAction').insert(actions).run() return userId } diff --git a/packages/server/safeMutations/removeSuggestedAction.ts b/packages/server/safeMutations/removeSuggestedAction.ts index 76d7aca0ad4..7d1d944d65c 100644 --- a/packages/server/safeMutations/removeSuggestedAction.ts +++ b/packages/server/safeMutations/removeSuggestedAction.ts @@ -1,8 +1,16 @@ +import {sql} from 'kysely' import getRethink from '../database/rethinkDriver' -import {TSuggestedActionTypeEnum} from '../graphql/types/SuggestedActionTypeEnum' +import getKysely from '../postgres/getKysely' +import {SuggestedAction} from '../postgres/pg' -const removeSuggestedAction = async (userId: string, type: TSuggestedActionTypeEnum) => { +const removeSuggestedAction = async (userId: string, type: SuggestedAction['type']) => { const r = await getRethink() + await getKysely() + .updateTable('SuggestedAction') + .set({removedAt: sql`CURRENT_TIMESTAMP`}) + .where('userId', '=', userId) + .where('type', '=', type) + .execute() return r .table('SuggestedAction') .getAll(userId, {index: 'userId'})