diff --git a/codegen.json b/codegen.json index b7e0e8e3f9b..e6b199fe13a 100644 --- a/codegen.json +++ b/codegen.json @@ -81,6 +81,7 @@ "Comment": "../../database/types/Comment#default as CommentDB", "Company": "./types/Company#CompanySource", "CreateGcalEventInput": "./types/CreateGcalEventInput#default", + "CreateGcalEventInput": "./types/CreateGcalEventInput#default", "CreateImposterTokenPayload": "./types/CreateImposterTokenPayload#CreateImposterTokenPayloadSource", "CreateStripeSubscriptionSuccess": "./types/CreateStripeSubscriptionSuccess#CreateStripeSubscriptionSuccessSource", "CreateTaskPayload": "./types/CreateTaskPayload#CreateTaskPayloadSource", @@ -117,6 +118,7 @@ "NotifyTeamArchived": "../../database/types/NotificationTeamArchived#default", "Organization": "./types/Organization#OrganizationSource", "TemplateScaleValue": "./types/TemplateScaleValue#TemplateScaleValueSource as TemplateScaleValueSourceDB", + "SuggestedAction": "../../postgres/types/index#SuggestedAction as SuggestedActionDB", "TemplateScale": "../../postgres/types/index#TemplateScale as TemplateScaleDB", "TemplateScaleRef": "../../postgres/types/index#TemplateScaleRef as TemplateScaleRefDB", "Threadable": "./types/Threadable#ThreadableSource", diff --git a/packages/client/modules/demo/ClientGraphQLServer.ts b/packages/client/modules/demo/ClientGraphQLServer.ts index d7e9d017ed2..eba6dfad064 100644 --- a/packages/client/modules/demo/ClientGraphQLServer.ts +++ b/packages/client/modules/demo/ClientGraphQLServer.ts @@ -265,7 +265,7 @@ class ClientGraphQLServer extends (EventEmitter as GQLDemoEmitter) { return { viewer: { ...this.db.users[0], - newMeeting: { + meeting: { ...this.db.newMeeting, reflectionGroups: ( this.db.newMeeting as {reflectionGroups: any[]} @@ -301,7 +301,7 @@ class ClientGraphQLServer extends (EventEmitter as GQLDemoEmitter) { return { viewer: { ...this.db.users[0], - newMeeting: { + meeting: { ...this.db.newMeeting, reflectionGroups: ( this.db.newMeeting as {reflectionGroups: any[]} diff --git a/packages/client/modules/demo/getDemoEntities.ts b/packages/client/modules/demo/getDemoEntities.ts index e099af859e7..a023e6d7d29 100644 --- a/packages/client/modules/demo/getDemoEntities.ts +++ b/packages/client/modules/demo/getDemoEntities.ts @@ -18,6 +18,30 @@ const query = graphql` const getDemoEntities = async (text: string) => { if (!text || text.length <= 2) return [] const remoteAtmosphere = new Atmosphere() + + const TEST_REFLECTIONS = { + 'Writing things down': [ + { + lemma: 'thing', + name: 'things', + salience: 1 + } + ], + 'Documenting things in Notion': [ + { + lemma: 'thing', + name: 'things', + salience: 0.8 + }, + { + lemma: 'notion', + name: 'notion', + salience: 0.2 + } + ] + } + const cachedEntities = TEST_REFLECTIONS[text as keyof typeof TEST_REFLECTIONS] + if (cachedEntities) return cachedEntities const res = await fetchQuery(remoteAtmosphere, query, {text}).toPromise() return res?.getDemoEntities?.entities ?? [] } diff --git a/packages/client/modules/demo/handleCompletedDemoStage.ts b/packages/client/modules/demo/handleCompletedDemoStage.ts index 7589b1fe285..896f2a4ded9 100644 --- a/packages/client/modules/demo/handleCompletedDemoStage.ts +++ b/packages/client/modules/demo/handleCompletedDemoStage.ts @@ -74,12 +74,8 @@ const addStageToBotScript = (stageId: string, db: RetroDemoDB, reflectionGroupId delay: 1000, variables } - if (Math.random() > 0.1) { - ops.push({...baseOp, botId: 'bot1'}) - } - if (Math.random() > 0.6) { - ops.push({...baseOp, botId: 'bot2'}) - } + const botId = Math.random() > 0.5 ? 'bot1' : 'bot2' + ops.push({...baseOp, botId}) }) stageTasks.forEach((taskContent, idx) => { const taskId = `botTask${stageId}:${idx}` diff --git a/packages/client/modules/email/components/MeetingSummaryEmailRootSSR.tsx b/packages/client/modules/email/components/MeetingSummaryEmailRootSSR.tsx index bd17389a344..2a26eb65bc0 100644 --- a/packages/client/modules/email/components/MeetingSummaryEmailRootSSR.tsx +++ b/packages/client/modules/email/components/MeetingSummaryEmailRootSSR.tsx @@ -9,7 +9,7 @@ import MeetingSummaryEmail from './SummaryEmail/MeetingSummaryEmail/MeetingSumma const query = graphql` query MeetingSummaryEmailRootSSRQuery($meetingId: ID!) { viewer { - newMeeting(meetingId: $meetingId) { + meeting(meetingId: $meetingId) { meetingType team { id @@ -42,9 +42,9 @@ const MeetingSummaryEmailRootSSR = (props: Props) => { // viewer will be null on initial SSR render if (!data?.viewer) return null const {viewer} = data - const {newMeeting} = viewer - if (!newMeeting) return null - const {team} = newMeeting + const {meeting} = viewer + if (!meeting) return null + const {team} = meeting const {id: teamId} = team const options = {searchParams: meetingSummaryUrlParams} const referrerUrl = makeAppURL(appOrigin, `new-summary/${meetingId}`, options) @@ -53,7 +53,7 @@ const MeetingSummaryEmailRootSSR = (props: Props) => { const emailCSVUrl = makeAppURL(appOrigin, `new-summary/${meetingId}/csv`, options) return ( ['newMeeting']> +type Meeting = NonNullable['meeting']> type ExportableTypeName = 'Task' | 'Reflection' | 'Comment' | 'Reply' interface CSVPokerRow { @@ -336,7 +336,7 @@ const ExportToCSV = (props: Props) => { onCompleted() if (!data) return const {viewer} = data - const {newMeeting} = viewer + const {meeting: newMeeting} = viewer if (!newMeeting) return const rows = getRows(newMeeting) if (rows.length === 0) return diff --git a/packages/client/modules/summary/components/NewMeetingSummary.tsx b/packages/client/modules/summary/components/NewMeetingSummary.tsx index 15674979357..318ebcd355a 100644 --- a/packages/client/modules/summary/components/NewMeetingSummary.tsx +++ b/packages/client/modules/summary/components/NewMeetingSummary.tsx @@ -26,7 +26,7 @@ const query = graphql` ...DashTopBar_query viewer { ...DashSidebar_viewer - newMeeting(meetingId: $meetingId) { + meeting(meetingId: $meetingId) { ...MeetingSummaryEmail_meeting ...MeetingLockedOverlay_meeting id @@ -49,7 +49,7 @@ const NewMeetingSummary = (props: Props) => { const {urlAction, queryRef} = props const data = usePreloadedQuery(query, queryRef) const {viewer} = data - const {newMeeting, teams} = viewer + const {meeting: newMeeting, teams} = viewer const activeMeetings = teams.flatMap((team) => team.activeMeetings).filter(Boolean) const {history} = useRouter() useEffect(() => { diff --git a/packages/integration-tests/tests/retrospective-demo/step2-group.test.ts b/packages/integration-tests/tests/retrospective-demo/step2-group.test.ts index ec163b556a8..401cd3277d3 100644 --- a/packages/integration-tests/tests/retrospective-demo/step2-group.test.ts +++ b/packages/integration-tests/tests/retrospective-demo/step2-group.test.ts @@ -67,9 +67,7 @@ test.describe('retrospective-demo / group page', () => { // Then it auto-generates a header await expect( - page.locator( - `[data-cy=group-column-Start] [data-cy*="Start-group-"] input[value="Documenting things in"]` - ) + page.locator(`[data-cy=group-column-Start] [data-cy*="Start-group-"] input[value="Things"]`) ).toBeVisible() // Then it shows all cards when clicking the group @@ -106,7 +104,9 @@ test.describe('retrospective-demo / group page', () => { await page.type(stopTextbox, 'Making decisions in one-on-one meetings') await page.press(stopTextbox, 'Enter') await expect( - page.locator('[data-cy="reflection-column-Stop"] :text("Making decisions in one-on-one meetings")') + page.locator( + '[data-cy="reflection-column-Stop"] :text("Making decisions in one-on-one meetings")' + ) ).toBeVisible() await goToNextPhase(page) @@ -119,7 +119,7 @@ test.describe('retrospective-demo / group page', () => { // Then it auto-generates a header await expect( page.locator( - `[data-cy=group-column-Start] [data-cy*="Start-group-"] input[value="Documenting things in"]` + `[data-cy=group-column-Start] [data-cy*="Start-group-"] input[value="Things Notion"]` ) ).toBeVisible() diff --git a/packages/integration-tests/tests/retrospective-demo/step4-discuss.test.ts b/packages/integration-tests/tests/retrospective-demo/step4-discuss.test.ts index ea497c4735e..2b8ef42d5f2 100644 --- a/packages/integration-tests/tests/retrospective-demo/step4-discuss.test.ts +++ b/packages/integration-tests/tests/retrospective-demo/step4-discuss.test.ts @@ -34,9 +34,7 @@ test.describe('retrospective-demo / discuss page', () => { // Then it auto-generates a header - we expect to see this in the sidebar on the discuss page await expect( - page.locator( - `[data-cy=group-column-Start] [data-cy*="Start-group-"] input[value="Documenting things in"]` - ) + page.locator(`[data-cy=group-column-Start] [data-cy*="Start-group-"] input[value="Things"]`) ).toBeVisible() await goToNextPhase(page) @@ -48,7 +46,7 @@ test.describe('retrospective-demo / discuss page', () => { await page.click('button[aria-label="Toggle the sidebar"]') } - await expect(page.locator('[data-cy=sidebar] :text("Documenting things in")')).toBeVisible() + await expect(page.locator('[data-cy=sidebar] :text("Things")')).toBeVisible() }) test('shows all the groups in the sidebar', async ({page, isMobile}) => { diff --git a/packages/server/database/rethinkDriver.ts b/packages/server/database/rethinkDriver.ts index 31b7aea2a4d..38e4268d917 100644 --- a/packages/server/database/rethinkDriver.ts +++ b/packages/server/database/rethinkDriver.ts @@ -26,9 +26,6 @@ import NotificationTeamInvitation from './types/NotificationTeamInvitation' import PasswordResetRequest from './types/PasswordResetRequest' import PushInvitation from './types/PushInvitation' import RetrospectivePrompt from './types/RetrospectivePrompt' -import SuggestedActionCreateNewTeam from './types/SuggestedActionCreateNewTeam' -import SuggestedActionInviteYourTeam from './types/SuggestedActionInviteYourTeam' -import SuggestedActionTryTheDemo from './types/SuggestedActionTryTheDemo' import Task from './types/Task' export type RethinkSchema = { @@ -127,12 +124,6 @@ export type RethinkSchema = { type: SlackNotification index: 'teamId' | 'userId' } - SuggestedAction: { - // tryRetroMeeting = 'tryRetroMeeting', - // tryActionMeeting = 'tryActionMeeting' - type: SuggestedActionCreateNewTeam | SuggestedActionInviteYourTeam | SuggestedActionTryTheDemo - index: 'userId' | 'teamId' - } Task: { type: Task index: diff --git a/packages/server/database/types/SuggestedAction.ts b/packages/server/database/types/SuggestedAction.ts deleted file mode 100644 index dabf8d2afec..00000000000 --- a/packages/server/database/types/SuggestedAction.ts +++ /dev/null @@ -1,30 +0,0 @@ -import generateUID from '../../generateUID' -import {TSuggestedActionTypeEnum} from '../../graphql/types/SuggestedActionTypeEnum' - -interface Input { - id?: string - createdAt?: Date - priority: number - removedAt?: Date | null - type: TSuggestedActionTypeEnum - userId: string -} - -export default abstract class SuggestedAction { - id: string - createdAt: Date - priority: number - removedAt: Date | null - type: TSuggestedActionTypeEnum - userId: string - - protected constructor(input: Input) { - const {type, userId, id, createdAt, priority, removedAt} = input - this.id = id || generateUID() - this.createdAt = createdAt || new Date() - this.userId = userId - this.type = type - this.priority = priority - this.removedAt = removedAt || null - } -} diff --git a/packages/server/database/types/SuggestedActionCreateNewTeam.ts b/packages/server/database/types/SuggestedActionCreateNewTeam.ts deleted file mode 100644 index 8a593d0bbed..00000000000 --- a/packages/server/database/types/SuggestedActionCreateNewTeam.ts +++ /dev/null @@ -1,13 +0,0 @@ -import SuggestedAction from './SuggestedAction' - -interface Input { - id?: string - createdAt?: Date - removedAt?: Date | null - userId: string -} -export default class SuggestedActionCreateNewTeam extends SuggestedAction { - constructor(input: Input) { - super({...input, type: 'createNewTeam', priority: 4}) - } -} diff --git a/packages/server/database/types/SuggestedActionInviteYourTeam.ts b/packages/server/database/types/SuggestedActionInviteYourTeam.ts deleted file mode 100644 index f24ab6b327f..00000000000 --- a/packages/server/database/types/SuggestedActionInviteYourTeam.ts +++ /dev/null @@ -1,17 +0,0 @@ -import SuggestedAction from './SuggestedAction' - -interface Input { - id?: string - createdAt?: Date - removedAt?: Date | null - userId: string - teamId: string -} -export default class SuggestedActionInviteYourTeam extends SuggestedAction { - teamId: string - constructor(input: Input) { - super({...input, type: 'inviteYourTeam', priority: 2}) - const {teamId} = input - this.teamId = teamId - } -} diff --git a/packages/server/database/types/SuggestedActionTryTheDemo.ts b/packages/server/database/types/SuggestedActionTryTheDemo.ts deleted file mode 100644 index 09a8d5f4bc8..00000000000 --- a/packages/server/database/types/SuggestedActionTryTheDemo.ts +++ /dev/null @@ -1,13 +0,0 @@ -import SuggestedAction from './SuggestedAction' - -interface Input { - id?: string - createdAt?: Date - removedAt?: Date | null - userId: string -} -export default class SuggestedActionTryTheDemo extends SuggestedAction { - constructor(input: Input) { - super({...input, type: 'tryTheDemo', priority: 1}) - } -} diff --git a/packages/server/dataloader/customLoaderMakers.ts b/packages/server/dataloader/customLoaderMakers.ts index 8443e74599f..aca91715545 100644 --- a/packages/server/dataloader/customLoaderMakers.ts +++ b/packages/server/dataloader/customLoaderMakers.ts @@ -55,12 +55,12 @@ export interface ReactablesKey { export interface UserTasksKey { first: number - after?: Date + after?: Date | null userIds: string[] teamIds: string[] archived?: boolean - statusFilters: TaskStatusEnum[] - filterQuery?: string + statusFilters?: TaskStatusEnum[] | null + filterQuery?: string | null includeUnassigned?: boolean } diff --git a/packages/server/dataloader/foreignKeyLoaderMakers.ts b/packages/server/dataloader/foreignKeyLoaderMakers.ts index b77d19e6fa0..32ada6428f3 100644 --- a/packages/server/dataloader/foreignKeyLoaderMakers.ts +++ b/packages/server/dataloader/foreignKeyLoaderMakers.ts @@ -154,8 +154,8 @@ export const templateDimensionsByScaleId = foreignKeyLoaderMaker( } ) -export const _suggestedActionsByUserId = foreignKeyLoaderMaker( - '_suggestedActions', +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 19724a66df5..66389f348a5 100644 --- a/packages/server/dataloader/primaryKeyLoaderMakers.ts +++ b/packages/server/dataloader/primaryKeyLoaderMakers.ts @@ -167,6 +167,6 @@ export const templateDimensions = primaryKeyLoaderMaker((ids: readonly string[]) return selectTemplateDimension().where('id', 'in', ids).execute() }) -export const _suggestedActions = primaryKeyLoaderMaker((ids: readonly string[]) => { +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 8251f871542..8cde8dc0bb6 100644 --- a/packages/server/dataloader/rethinkForeignKeyLoaderMakers.ts +++ b/packages/server/dataloader/rethinkForeignKeyLoaderMakers.ts @@ -134,19 +134,6 @@ export const slackNotificationsByTeamId = new RethinkForeignKeyLoaderMaker( } ) -export const suggestedActionsByUserId = new RethinkForeignKeyLoaderMaker( - 'suggestedActions', - 'userId', - async (userIds) => { - const r = await getRethink() - return r - .table('SuggestedAction') - .getAll(r.args(userIds), {index: 'userId'}) - .filter((row: any) => row('removedAt').default(null).eq(null)) - .run() - } -) - export const tasksByDiscussionId = new RethinkForeignKeyLoaderMaker( 'tasks', 'discussionId', diff --git a/packages/server/dataloader/rethinkPrimaryKeyLoaderMakers.ts b/packages/server/dataloader/rethinkPrimaryKeyLoaderMakers.ts index 95c481cfd25..5d015a2042a 100644 --- a/packages/server/dataloader/rethinkPrimaryKeyLoaderMakers.ts +++ b/packages/server/dataloader/rethinkPrimaryKeyLoaderMakers.ts @@ -15,6 +15,5 @@ export const newFeatures = new RethinkPrimaryKeyLoaderMaker('NewFeature') export const notifications = new RethinkPrimaryKeyLoaderMaker('Notification') export const slackAuths = new RethinkPrimaryKeyLoaderMaker('SlackAuth') export const slackNotifications = new RethinkPrimaryKeyLoaderMaker('SlackNotification') -export const suggestedActions = new RethinkPrimaryKeyLoaderMaker('SuggestedAction') export const tasks = new RethinkPrimaryKeyLoaderMaker('Task') export const teamInvitations = new RethinkPrimaryKeyLoaderMaker('TeamInvitation') diff --git a/packages/server/graphql/mutations/dismissSuggestedAction.ts b/packages/server/graphql/mutations/dismissSuggestedAction.ts index 5c34d8ccf6c..46a0baada2c 100644 --- a/packages/server/graphql/mutations/dismissSuggestedAction.ts +++ b/packages/server/graphql/mutations/dismissSuggestedAction.ts @@ -1,5 +1,4 @@ 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' @@ -20,7 +19,6 @@ export default { {suggestedActionId}: {suggestedActionId: string}, {authToken, dataLoader}: GQLContext ) => { - const r = await getRethink() const now = new Date() const viewerId = getUserId(authToken) @@ -40,7 +38,6 @@ export default { .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 return { diff --git a/packages/server/graphql/mutations/helpers/bootstrapNewUser.ts b/packages/server/graphql/mutations/helpers/bootstrapNewUser.ts index 8e855486ad2..c1e5edb1800 100644 --- a/packages/server/graphql/mutations/helpers/bootstrapNewUser.ts +++ b/packages/server/graphql/mutations/helpers/bootstrapNewUser.ts @@ -1,4 +1,3 @@ -import getRethink from '../../../database/rethinkDriver' import AuthToken from '../../../database/types/AuthToken' import TimelineEventJoinedParabol from '../../../database/types/TimelineEventJoinedParabol' import User from '../../../database/types/User' @@ -21,7 +20,6 @@ const bootstrapNewUser = async ( isOrganic: boolean, dataLoader: DataLoaderWorker ) => { - const r = await getRethink() const { id: userId, createdAt, @@ -115,14 +113,9 @@ const bootstrapNewUser = async ( return Promise.all([ acceptTeamInvitation(team, userId, dataLoader), isOrganic - ? Promise.all([ - pg.insertInto('SuggestedAction').values(inviteYourTeam).execute(), - r.table('SuggestedAction').insert(inviteYourTeam).run() - ]) - : Promise.all([ - pg.insertInto('SuggestedAction').values(actions).execute(), - r.table('SuggestedAction').insert(actions).run() - ]), + ? pg.insertInto('SuggestedAction').values(inviteYourTeam).execute() + : pg.insertInto('SuggestedAction').values(actions).execute(), + , analytics.autoJoined(newUser, teamId) ]) }) @@ -147,7 +140,6 @@ const bootstrapNewUser = async ( analytics.newOrg(newUser, orgId, teamId, true) } else { 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 9da9169e175..f9c9bdf6d73 100644 --- a/packages/server/graphql/mutations/helpers/createTeamAndLeader.ts +++ b/packages/server/graphql/mutations/helpers/createTeamAndLeader.ts @@ -83,15 +83,5 @@ export default async function createTeamAndLeader( // 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/public/typeDefs/SuggestedAction.graphql b/packages/server/graphql/public/typeDefs/SuggestedAction.graphql index 72f462bafc7..32d2519a88b 100644 --- a/packages/server/graphql/public/typeDefs/SuggestedAction.graphql +++ b/packages/server/graphql/public/typeDefs/SuggestedAction.graphql @@ -20,7 +20,7 @@ interface SuggestedAction { """ * The timestamp the action was removed at """ - removedAt: DateTime! + removedAt: DateTime """ The specific type of suggested action diff --git a/packages/server/graphql/public/typeDefs/SuggestedActionCreateNewTeam.graphql b/packages/server/graphql/public/typeDefs/SuggestedActionCreateNewTeam.graphql index 3d188acbbc1..abb5d00b619 100644 --- a/packages/server/graphql/public/typeDefs/SuggestedActionCreateNewTeam.graphql +++ b/packages/server/graphql/public/typeDefs/SuggestedActionCreateNewTeam.graphql @@ -20,7 +20,7 @@ type SuggestedActionCreateNewTeam implements SuggestedAction { """ * The timestamp the action was removed at """ - removedAt: DateTime! + removedAt: DateTime """ The specific type of suggested action diff --git a/packages/server/graphql/public/typeDefs/SuggestedActionInviteYourTeam.graphql b/packages/server/graphql/public/typeDefs/SuggestedActionInviteYourTeam.graphql index d39f8126a2e..2a9542fdffe 100644 --- a/packages/server/graphql/public/typeDefs/SuggestedActionInviteYourTeam.graphql +++ b/packages/server/graphql/public/typeDefs/SuggestedActionInviteYourTeam.graphql @@ -20,7 +20,7 @@ type SuggestedActionInviteYourTeam implements SuggestedAction { """ * The timestamp the action was removed at """ - removedAt: DateTime! + removedAt: DateTime """ The specific type of suggested action diff --git a/packages/server/graphql/public/typeDefs/SuggestedActionTryActionMeeting.graphql b/packages/server/graphql/public/typeDefs/SuggestedActionTryActionMeeting.graphql index 54e984ffd5d..6973fa96dee 100644 --- a/packages/server/graphql/public/typeDefs/SuggestedActionTryActionMeeting.graphql +++ b/packages/server/graphql/public/typeDefs/SuggestedActionTryActionMeeting.graphql @@ -20,7 +20,7 @@ type SuggestedActionTryActionMeeting implements SuggestedAction { """ * The timestamp the action was removed at """ - removedAt: DateTime! + removedAt: DateTime """ The specific type of suggested action diff --git a/packages/server/graphql/public/typeDefs/SuggestedActionTryRetroMeeting.graphql b/packages/server/graphql/public/typeDefs/SuggestedActionTryRetroMeeting.graphql index c95dab4238c..04adec5fe7d 100644 --- a/packages/server/graphql/public/typeDefs/SuggestedActionTryRetroMeeting.graphql +++ b/packages/server/graphql/public/typeDefs/SuggestedActionTryRetroMeeting.graphql @@ -20,7 +20,7 @@ type SuggestedActionTryRetroMeeting implements SuggestedAction { """ * The timestamp the action was removed at """ - removedAt: DateTime! + removedAt: DateTime """ The specific type of suggested action diff --git a/packages/server/graphql/public/typeDefs/SuggestedActionTryTheDemo.graphql b/packages/server/graphql/public/typeDefs/SuggestedActionTryTheDemo.graphql index cf2c70feb1e..56c20c4fe71 100644 --- a/packages/server/graphql/public/typeDefs/SuggestedActionTryTheDemo.graphql +++ b/packages/server/graphql/public/typeDefs/SuggestedActionTryTheDemo.graphql @@ -20,7 +20,7 @@ type SuggestedActionTryTheDemo implements SuggestedAction { """ * The timestamp the action was removed at """ - removedAt: DateTime! + removedAt: DateTime """ The specific type of suggested action diff --git a/packages/server/graphql/public/typeDefs/User.graphql b/packages/server/graphql/public/typeDefs/User.graphql index c154a6aae5e..6acd37dfc59 100644 --- a/packages/server/graphql/public/typeDefs/User.graphql +++ b/packages/server/graphql/public/typeDefs/User.graphql @@ -206,16 +206,6 @@ type User { meetingId: ID! ): NewMeeting - """ - A previous meeting that the user was in (present or absent) - """ - newMeeting( - """ - The meeting ID - """ - meetingId: ID! - ): NewMeeting - """ all the notifications for a single user """ @@ -289,7 +279,7 @@ type User { """ a list of user Ids that you want tasks for. if null, will return tasks for all possible team members. An id is null if it is not assigned to anyone. """ - userIds: [ID] + userIds: [ID!] """ a list of team Ids that you want tasks for. if null, will return tasks for all possible active teams diff --git a/packages/server/graphql/public/types/ReqResolvers.d.ts b/packages/server/graphql/public/types/ReqResolvers.d.ts new file mode 100644 index 00000000000..e7928318ce2 --- /dev/null +++ b/packages/server/graphql/public/types/ReqResolvers.d.ts @@ -0,0 +1,13 @@ +import {Resolvers, ResolversParentTypes} from '../resolverTypes' + +export type ReqResolvers< + T extends keyof Resolvers, + R = NonNullable, + TParent = ResolversParentTypes[T] + // If we aren't using a custom mapper, return the normal type +> = TParent extends {__typename?: string} + ? R + : // else, require all types that don't exist on the mapper + R & { + [P in keyof Omit]-?: R[P] + } diff --git a/packages/server/graphql/public/types/SuggestedActionCreateNewTeam.ts b/packages/server/graphql/public/types/SuggestedActionCreateNewTeam.ts new file mode 100644 index 00000000000..1d677b36ba6 --- /dev/null +++ b/packages/server/graphql/public/types/SuggestedActionCreateNewTeam.ts @@ -0,0 +1,7 @@ +import {SuggestedActionCreateNewTeamResolvers} from '../resolverTypes' + +const SuggestedActionCreateNewTeam: SuggestedActionCreateNewTeamResolvers = { + __isTypeOf: ({type}) => type === 'createNewTeam' +} + +export default SuggestedActionCreateNewTeam diff --git a/packages/server/graphql/public/types/SuggestedActionInviteYourTeam.ts b/packages/server/graphql/public/types/SuggestedActionInviteYourTeam.ts new file mode 100644 index 00000000000..eb46afc133f --- /dev/null +++ b/packages/server/graphql/public/types/SuggestedActionInviteYourTeam.ts @@ -0,0 +1,10 @@ +import {SuggestedActionInviteYourTeamResolvers} from '../resolverTypes' + +const SuggestedActionInviteYourTeam: SuggestedActionInviteYourTeamResolvers = { + __isTypeOf: ({type}) => type === 'inviteYourTeam', + team: ({teamId}, _args, {dataLoader}) => { + return dataLoader.get('teams').loadNonNull(teamId) + } +} + +export default SuggestedActionInviteYourTeam diff --git a/packages/server/graphql/public/types/SuggestedActionTryActionMeeting.ts b/packages/server/graphql/public/types/SuggestedActionTryActionMeeting.ts new file mode 100644 index 00000000000..65fc93564f7 --- /dev/null +++ b/packages/server/graphql/public/types/SuggestedActionTryActionMeeting.ts @@ -0,0 +1,10 @@ +import {SuggestedActionTryActionMeetingResolvers} from '../resolverTypes' + +const SuggestedActionTryActionMeeting: SuggestedActionTryActionMeetingResolvers = { + __isTypeOf: ({type}) => type === 'tryActionMeeting', + team: ({teamId}, _args, {dataLoader}) => { + return dataLoader.get('teams').loadNonNull(teamId) + } +} + +export default SuggestedActionTryActionMeeting diff --git a/packages/server/graphql/public/types/SuggestedActionTryRetroMeeting.ts b/packages/server/graphql/public/types/SuggestedActionTryRetroMeeting.ts new file mode 100644 index 00000000000..bd219999e80 --- /dev/null +++ b/packages/server/graphql/public/types/SuggestedActionTryRetroMeeting.ts @@ -0,0 +1,10 @@ +import {SuggestedActionTryRetroMeetingResolvers} from '../resolverTypes' + +const SuggestedActionTryRetroMeeting: SuggestedActionTryRetroMeetingResolvers = { + __isTypeOf: ({type}) => type === 'tryRetroMeeting', + team: ({teamId}, _args, {dataLoader}) => { + return dataLoader.get('teams').loadNonNull(teamId) + } +} + +export default SuggestedActionTryRetroMeeting diff --git a/packages/server/graphql/public/types/SuggestedActionTryTheDemo.ts b/packages/server/graphql/public/types/SuggestedActionTryTheDemo.ts new file mode 100644 index 00000000000..384a24ae72f --- /dev/null +++ b/packages/server/graphql/public/types/SuggestedActionTryTheDemo.ts @@ -0,0 +1,7 @@ +import {SuggestedActionTryTheDemoResolvers} from '../resolverTypes' + +const SuggestedActionTryTheDemo: SuggestedActionTryTheDemoResolvers = { + __isTypeOf: ({type}) => type === 'tryTheDemo' +} + +export default SuggestedActionTryTheDemo diff --git a/packages/server/graphql/public/types/User.ts b/packages/server/graphql/public/types/User.ts index 183ef7cee31..468f032903b 100644 --- a/packages/server/graphql/public/types/User.ts +++ b/packages/server/graphql/public/types/User.ts @@ -1,13 +1,26 @@ import base64url from 'base64url' import ms from 'ms' import DomainJoinRequestId from 'parabol-client/shared/gqlIds/DomainJoinRequestId' +import MeetingMemberId from 'parabol-client/shared/gqlIds/MeetingMemberId' +import isTaskPrivate from 'parabol-client/utils/isTaskPrivate' import {isNotNull} from 'parabol-client/utils/predicates' +import toTeamMemberId from 'parabol-client/utils/relay/toTeamMemberId' import {Threshold} from '../../../../client/types/constEnums' +import { + AUTO_GROUPING_THRESHOLD, + MAX_REDUCTION_PERCENTAGE, + MAX_RESULT_GROUP_SIZE +} from '../../../../client/utils/constants' +import groupReflections from '../../../../client/utils/smartGroup/groupReflections' import fetchAllLines from '../../../billing/helpers/fetchAllLines' import generateInvoice from '../../../billing/helpers/generateInvoice' import generateUpcomingInvoice from '../../../billing/helpers/generateUpcomingInvoice' import getRethink from '../../../database/rethinkDriver' +import {RDatum, RValue} from '../../../database/stricterR' +import Invoice from '../../../database/types/Invoice' +import MeetingMemberType from '../../../database/types/MeetingMember' import MeetingTemplate from '../../../database/types/MeetingTemplate' +import Task from '../../../database/types/Task' import getKysely from '../../../postgres/getKysely' import { getUserId, @@ -16,15 +29,21 @@ import { isUserBillingLeader } from '../../../utils/authorization' import getDomainFromEmail from '../../../utils/getDomainFromEmail' +import getMonthlyStreak from '../../../utils/getMonthlyStreak' +import getRedis from '../../../utils/getRedis' import {getSSOMetadataFromURL} from '../../../utils/getSSOMetadataFromURL' import sendToSentry from '../../../utils/sendToSentry' import standardError from '../../../utils/standardError' import {getStripeManager} from '../../../utils/stripe' +import errorFilter from '../../errorFilter' +import {DataLoaderWorker} from '../../graphql' import isValid from '../../isValid' +import connectionFromTasks from '../../queries/helpers/connectionFromTasks' import connectionFromTemplateArray from '../../queries/helpers/connectionFromTemplateArray' +import makeUpcomingInvoice from '../../queries/helpers/makeUpcomingInvoice' import {getFeatureTier} from '../../types/helpers/getFeatureTier' import getSignOnURL from '../mutations/helpers/SAMLHelpers/getSignOnURL' -import {UserResolvers} from '../resolverTypes' +import {ReqResolvers} from './ReqResolvers' declare const __PRODUCTION__: string @@ -42,7 +61,636 @@ const EMBED_URL = (() => { })() const SIMILARITY_THRESHOLD = 0.5 -const User: UserResolvers = { +const getValidUserIds = async ( + userIds: null | string[] | undefined, + viewerId: string, + validTeamIds: string[], + dataLoader: DataLoaderWorker +) => { + if (!userIds) return null + if (userIds.length === 1 && userIds[0] === viewerId) return userIds + // NOTE: this will filter out ex-teammembers. if that's a problem, we should use a different dataloader + const teamMembersByUserIds = ( + await dataLoader.get('teamMembersByUserId').loadMany(userIds as string[]) + ).filter(errorFilter) + const teamMembersOnValidTeams = teamMembersByUserIds + .flat() + .filter((teamMember) => validTeamIds.includes(teamMember.teamId)) + const teamMemberUserIds = new Set( + teamMembersOnValidTeams.map(({userId}: {userId: string}) => userId) + ) + return userIds.filter((userId) => teamMemberUserIds.has(userId)) +} + +const User: ReqResolvers<'User'> = { + organization: async (_source, {orgId}, {authToken, dataLoader}) => { + const viewerId = getUserId(authToken) + const [organization, viewerOrganizationUser] = await Promise.all([ + dataLoader.get('organizations').loadNonNull(orgId), + dataLoader.get('organizationUsersByUserIdOrgId').load({userId: viewerId, orgId}) + ]) + if (!isSuperUser(authToken) && !viewerOrganizationUser) return null + return organization + }, + invoices: async (_source, {orgId, first, after}, {authToken, dataLoader}) => { + const r = await getRethink() + + // AUTH + const viewerId = getUserId(authToken) + if (!(await isUserBillingLeader(viewerId, orgId, dataLoader))) { + // standardError(new Error('Not organization lead'), {userId: viewerId}) + return { + edges: [], + pageInfo: { + hasNextPage: false, + hasPreviousPage: false + } + } + } + + // RESOLUTION + const {stripeId} = await dataLoader.get('organizations').loadNonNull(orgId) + const dbAfter = after ? new Date(after) : r.maxval + const [tooManyInvoices, orgUsers] = await Promise.all([ + r + .table('Invoice') + .between([orgId, r.minval], [orgId, dbAfter], { + index: 'orgIdStartAt', + leftBound: 'open', + rightBound: 'closed' + }) + .filter((invoice: RDatum) => invoice('status').ne('UPCOMING').and(invoice('total').ne(0))) + // it's possible that stripe gives the same startAt to 2 invoices (the first $5 charge & the next) + // break ties based on when created. In the future, we might want to consider using the created_at provided by stripe instead of our own + .orderBy(r.desc('startAt'), r.desc('createdAt')) + .limit(first + 1) + .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 + : await makeUpcomingInvoice(org, orgUserCount, stripeId) + const extraInvoices: Invoice[] = tooManyInvoices || [] + const paginatedInvoices = after ? extraInvoices.slice(1) : extraInvoices + const allInvoices = upcomingInvoice + ? [upcomingInvoice, ...paginatedInvoices] + : paginatedInvoices + const nodes = allInvoices.slice(0, first) + const edges = nodes.map((node) => ({ + cursor: node.startAt, + node + })) + const firstEdge = edges[0] + return { + edges, + pageInfo: { + startCursor: firstEdge && firstEdge.cursor, + endCursor: firstEdge && edges[edges.length - 1]!.cursor, + hasNextPage: extraInvoices.length + (upcomingInvoice ? 1 : 0) > first, + hasPreviousPage: false + } + } + }, + archivedTasks: async (_source, {first, after, teamId}, {authToken}) => { + const r = await getRethink() + + // AUTH + const userId = getUserId(authToken) + if (!isTeamMember(authToken, teamId)) { + standardError(new Error('Not organization lead'), {userId}) + return null + } + + // RESOLUTION + const teamMemberId = `${userId}::${teamId}` + const dbAfter = after ? new Date(after) : r.maxval + const tasks = await r + .table('Task') + // use a compound index so we can easily paginate later + .between([teamId, r.minval], [teamId, dbAfter], { + index: 'teamIdUpdatedAt' + }) + .filter((task: RValue) => + task('tags') + .contains('archived') + .and( + r.branch(task('tags').contains('private'), task('teamMemberId').eq(teamMemberId), true) + ) + ) + .orderBy(r.desc('updatedAt')) + .limit(first + 1) + .coerceTo('array') + .run() + + const nodes = tasks.slice(0, first) + const edges = nodes.map((node) => ({ + cursor: node.updatedAt, + node + })) + const firstEdge = edges[0] + return { + edges, + pageInfo: { + startCursor: firstEdge && firstEdge.cursor, + endCursor: firstEdge ? edges[edges.length - 1]!.cursor : new Date(), + hasNextPage: tasks.length > nodes.length, + hasPreviousPage: false + } + } + }, + archivedTasksCount: async (_source, {teamId}, {authToken}) => { + const r = await getRethink() + const viewerId = getUserId(authToken) + + // AUTH + const userId = getUserId(authToken) + if (!isTeamMember(authToken, teamId)) { + standardError(new Error('Team not found'), {userId: viewerId}) + return 0 + } + + // RESOLUTION + const teamMemberId = `${userId}::${teamId}` + return r + .table('Task') + .between([teamId, r.minval], [teamId, r.maxval], { + index: 'teamIdUpdatedAt' + }) + .filter((task: RValue) => + task('tags') + .contains('archived') + .and( + r.branch(task('tags').contains('private'), task('teamMemberId').eq(teamMemberId), true) + ) + ) + .count() + .run() + }, + meeting: async (_source, {meetingId}, {authToken, dataLoader}) => { + const viewerId = getUserId(authToken) + const meeting = await dataLoader.get('newMeetings').load(meetingId) + if (!meeting) { + standardError(new Error('Meeting not found'), {userId: viewerId, tags: {meetingId}}) + return null + } + const {teamId} = meeting + if (!isTeamMember(authToken, teamId)) { + const meetingMemberId = toTeamMemberId(meetingId, viewerId) + const meetingMember = await dataLoader.get('meetingMembers').load(meetingMemberId) + if (!meetingMember) { + // standardError(new Error('Team not found'), {userId: viewerId, tags: {teamId}}) + return null + } + } + return meeting + }, + notifications: async (_source, {first, after, types}, {authToken}) => { + const r = await getRethink() + // AUTH + const userId = getUserId(authToken) + const dbAfter = after || r.maxval + // RESOLUTION + // TODO consider moving the requestedFields to all queries + const nodesPlus1 = await r + .table('Notification') + .getAll(userId, {index: 'userId'}) + .orderBy(r.desc('createdAt')) + .filter((row: RDatum) => { + if (types) { + return row('createdAt') + .lt(dbAfter) + .and(r.expr(types).contains(row('type'))) + } + return row('createdAt').lt(dbAfter) + }) + .limit(first + 1) + .run() + + const nodes = nodesPlus1.slice(0, first) + const edges = nodes.map((node) => ({ + cursor: node.createdAt, + node + })) + const lastEdge = edges[edges.length - 1] + return { + edges, + pageInfo: { + endCursor: lastEdge?.cursor, + hasNextPage: nodesPlus1.length > first, + hasPreviousPage: false + } + } + }, + tasks: async ( + _source, + {first, after, userIds, teamIds, archived, statusFilters, filterQuery, includeUnassigned}, + {authToken, dataLoader} + ) => { + // AUTH + const viewerId = getUserId(authToken) + // VALIDATE + if ((teamIds && teamIds.length > 100) || (userIds && userIds.length > 100)) { + const err = new Error('Task filter is too broad') + standardError(err, { + userId: viewerId, + tags: {userIds: JSON.stringify(userIds), teamIds: JSON.stringify(teamIds)} + }) + return connectionFromTasks([], 0, err) + } + // common queries + // - give me all the tasks for a particular team (users: all, team: abc) + // - give me all the tasks for a particular user (users: 123, team: all) + // - give me all the tasks for a number of teams (users: all, team: [abc, def]) + // - give me all the tasks for a number of users (users: [123, 456], team: all) + // - give me all the tasks for a set of users & teams (users: [123, 456], team: [abc, def]) + // - give me all the tasks for all the users on all the teams (users: all, team: all) + + // if archived is true & no userId filter is provided, it should include tasks for ex-team members + // under no condition should it show tasks for archived teams + const accessibleTeamIds = authToken.tms + const validTeamIds = teamIds + ? teamIds.filter((teamId: string) => accessibleTeamIds.includes(teamId)) + : accessibleTeamIds + const validUserIds = (await getValidUserIds(userIds, viewerId, validTeamIds, dataLoader)) ?? [] + // RESOLUTION + const tasks = await dataLoader.get('userTasks').load({ + first, + after, + userIds: validUserIds, + teamIds: validTeamIds, + archived, + statusFilters, + filterQuery, + includeUnassigned + }) + const filteredTasks = tasks.filter((task: Task) => { + if (isTaskPrivate(task.tags) && task.userId !== viewerId) return false + return true + }) + return connectionFromTasks(filteredTasks, first) + }, + team: async (_source, {teamId}, {authToken, dataLoader}, {operation}) => { + // HANDLED_OPS is a list of operations that we gracefully handle on the client, so we don't want to report them to sentry + const HANDLED_OPS = ['TeamRootQuery', 'TeamContainerQuery'] + const team = await dataLoader.get('teams').loadNonNull(teamId) + const {orgId} = team + const viewerId = getUserId(authToken) + const {role} = + (await dataLoader.get('organizationUsersByUserIdOrgId').load({userId: viewerId, orgId})) ?? {} + const isOrgAdmin = role === 'ORG_ADMIN' + if (!isOrgAdmin && !isTeamMember(authToken, teamId) && !isSuperUser(authToken)) { + const viewerId = getUserId(authToken) + if (!HANDLED_OPS.includes(operation?.name?.value ?? '')) { + standardError(new Error('Team not found'), {userId: viewerId}) + } + return null + } + return dataLoader.get('teams').loadNonNull(teamId) + }, + createdAt: ({createdAt}) => createdAt || new Date('2016-06-01'), + + isAnyBillingLeader: async ({id: userId}, _args, {dataLoader}) => { + const organizationUsers = await dataLoader.get('organizationUsersByUserId').load(userId) + return organizationUsers.some( + (organizationUser) => + organizationUser.role === 'BILLING_LEADER' || organizationUser.role === 'ORG_ADMIN' + ) + }, + + isConnected: async ({id: userId}) => { + const redis = getRedis() + const connectedSocketsCount = await redis.llen(`presence:${userId}`) + return connectedSocketsCount > 0 + }, + + isPatientZero: ({isPatient0}) => isPatient0, + + isRemoved: ({isRemoved}) => !!isRemoved, + + isWatched: ({isWatched}) => !!isWatched, + + lastMetAt: async ({id: userId}, _args, {dataLoader}) => { + const meetingMembers = await dataLoader.get('meetingMembersByUserId').load(userId) + const lastMetAt = Math.max( + 0, + ...meetingMembers.map(({updatedAt}: MeetingMemberType) => updatedAt.getTime()) + ) + return lastMetAt ? new Date(lastMetAt) : null + }, + + meetingCount: async ({id: userId}, _args, {dataLoader}) => { + const meetingMembers = await dataLoader.get('meetingMembersByUserId').load(userId) + return meetingMembers.length + }, + + monthlyStreakMax: async ({id: userId}, _args, {dataLoader}) => { + const meetingMembers = await dataLoader.get('meetingMembersByUserId').load(userId) + const meetingDates = meetingMembers + .map(({updatedAt}: MeetingMemberType) => updatedAt.getTime()) + .sort((a, b) => (a < b ? 1 : -1)) + + return getMonthlyStreak(meetingDates) + }, + + monthlyStreakCurrent: async ({id: userId}, _args, {dataLoader}) => { + const meetingMembers = await dataLoader.get('meetingMembersByUserId').load(userId) + const meetingDates = meetingMembers + .map(({updatedAt}: MeetingMemberType) => updatedAt.getTime()) + .sort((a, b) => (a < b ? 1 : -1)) + return getMonthlyStreak(meetingDates, true) + }, + + suggestedActions: async ({id: userId}, _args, {dataLoader, authToken}) => { + const viewerId = getUserId(authToken) + if (viewerId !== userId) return [] + const suggestedActions = await dataLoader.get('suggestedActionsByUserId').load(userId) + suggestedActions.sort((a, b) => (a.priority! < b.priority! ? -1 : 1)) + return suggestedActions + }, + + payLaterClickCount: ({payLaterClickCount}) => payLaterClickCount || 0, + + timeline: async ({id}, {after, first, teamIds, eventTypes}, {authToken, dataLoader}) => { + const viewerId = getUserId(authToken) + + // VALIDATE + if (teamIds && teamIds.length > 100) { + const error = new Error('Timeline filter is too broad') + standardError(error, { + userId: viewerId, + tags: {teamIds: JSON.stringify(teamIds)} + }) + return { + error, + pageInfo: { + startCursor: after, + endCursor: after, + hasNextPage: false, + hasPreviousPage: false + }, + edges: [] + } + } + const userTeamMembers = await dataLoader.get('teamMembersByUserId').load(viewerId) + const accessibleTeamIds = userTeamMembers.map(({teamId}) => teamId) + const validTeamIds = teamIds + ? teamIds.filter((teamId: string) => accessibleTeamIds.includes(teamId)) + : accessibleTeamIds + + if (viewerId !== id && !isSuperUser(authToken)) + return {error: 'Not user', pageInfo: {hasNextPage: false, hasPreviousPage: false}, edges: []} + 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((eb) => eb.between('createdAt', minVal, dbAfter)) + .where('isActive', '=', true) + .where('teamId', 'in', validTeamIds) + .$if(!!eventTypes, (db) => db.where('type', 'in', eventTypes!)) + .orderBy('createdAt', 'desc') + .limit(first + 1) + .execute() + const edges = events.slice(0, first).map((node) => ({ + cursor: node.createdAt, + node + })) + const [firstEdge] = edges + return { + // FIXME orgId can be null sometimes + edges: edges as any, + pageInfo: { + startCursor: firstEdge ? firstEdge.cursor : null, + // FIXME: the PageInfo type should be a GraphQLISO8601 type, but fixing that requires more work + // because the type is shared all over so we'll have to verify that the change doesn't break anything + endCursor: firstEdge ? (new Date(edges[edges.length - 1]!.cursor).toJSON() as any) : null, + hasNextPage: events.length > edges.length, + hasPreviousPage: false + } + } + }, + + discussion: async (_source, {id}, {authToken, dataLoader}) => { + const discussion = await dataLoader.get('discussions').load(id) + if (!discussion) return null + const {teamId} = discussion + if (!isTeamMember(authToken, teamId)) { + return null + } + return discussion + }, + + newFeature: ({newFeatureId}, _args, {dataLoader}) => { + return newFeatureId ? dataLoader.get('newFeatures').load(newFeatureId) : null + }, + + lastSeenAtURLs: async ({id: userId}) => { + const redis = getRedis() + const userPresence = await redis.lrange(`presence:${userId}`, 0, -1) + if (!userPresence || userPresence.length === 0) return null + return userPresence.map((socket) => JSON.parse(socket).lastSeenAtURL) + }, + + meetingMember: async ({id: userId}, {meetingId}, {dataLoader}) => { + const meetingMemberId = toTeamMemberId(meetingId, userId) + return meetingId ? dataLoader.get('meetingMembers').load(meetingMemberId) : undefined + }, + + organizationUser: async ({id: userId}, {orgId}, {authToken, dataLoader}) => { + const viewerId = getUserId(authToken) + const [viewerOrganizationUser, userOrganizationUser] = await Promise.all([ + dataLoader.get('organizationUsersByUserIdOrgId').load({userId: viewerId, orgId}), + dataLoader.get('organizationUsersByUserIdOrgId').load({userId, orgId}) + ]) + if (viewerOrganizationUser || isSuperUser(authToken)) return userOrganizationUser + return null + }, + + organizationUsers: async ({id: userId}, _args, {authToken, dataLoader}) => { + const viewerId = getUserId(authToken) + const organizationUsers = await dataLoader.get('organizationUsersByUserId').load(userId) + 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}) => orgId) + return organizationUsers.filter((organizationUser) => + viewerOrgIds.includes(organizationUser.orgId) + ) + }, + + organizations: async ({id: userId}, _args, {authToken, dataLoader}) => { + const organizationUsers = await dataLoader.get('organizationUsersByUserId').load(userId) + const orgIds = organizationUsers.map(({orgId}) => orgId) + const organizations = (await dataLoader.get('organizations').loadMany(orgIds)).filter(isValid) + organizations.sort((a, b) => (a.name > b.name ? 1 : -1)) + const viewerId = getUserId(authToken) + if (viewerId === userId || isSuperUser(authToken)) { + return organizations + } + const viewerOrganizationUsers = await dataLoader.get('organizationUsersByUserId').load(viewerId) + const viewerOrgIds = viewerOrganizationUsers.map(({orgId}) => orgId) + return organizations.filter((organization) => viewerOrgIds.includes(organization.id)) + }, + + overLimitCopy: async ({id: userId, overLimitCopy}, _args, {dataLoader}) => { + const organizationUsers = await dataLoader.get('organizationUsersByUserId').load(userId) + const isAnyMemberOfPaidOrg = organizationUsers.some( + (organizationUser) => organizationUser.tier !== 'starter' + ) + if (isAnyMemberOfPaidOrg) return null + return overLimitCopy + }, + + similarReflectionGroups: async ( + {id: userId}, + {reflectionGroupId, searchQuery: rawSearchQuery}, + {dataLoader} + ) => { + const searchQuery = rawSearchQuery.toLowerCase().trim() + const retroReflectionGroup = await dataLoader + .get('retroReflectionGroups') + .load(reflectionGroupId) + if (!retroReflectionGroup) { + throw new Error('Invalid reflection group id') + } + const {meetingId} = retroReflectionGroup + const meetingMemberId = MeetingMemberId.join(meetingId, userId) + const [viewerMeetingMember, reflections] = await Promise.all([ + dataLoader.get('meetingMembers').load(meetingMemberId), + dataLoader.get('retroReflectionsByMeetingId').load(meetingId) + ]) + if (!viewerMeetingMember) { + throw new Error('Not a member of meeting') + } + + if (searchQuery !== '') { + const matchedReflections = reflections.filter(({plaintextContent}) => + plaintextContent.toLowerCase().includes(searchQuery) + ) + const relatedReflections = matchedReflections.filter( + ({reflectionGroupId: groupId}) => groupId !== reflectionGroupId + ) + const relatedGroupIds = [ + ...new Set(relatedReflections.map(({reflectionGroupId}) => reflectionGroupId)) + ].slice(0, MAX_RESULT_GROUP_SIZE) + return (await dataLoader.get('retroReflectionGroups').loadMany(relatedGroupIds)).filter( + isValid + ) + } + + const reflectionsCount = reflections.length + const spotlightResultGroupSize = Math.min(reflectionsCount - 1, MAX_RESULT_GROUP_SIZE) + let currentResultGroupIds = new Set() + let currentThresh: number | null = AUTO_GROUPING_THRESHOLD + while (currentThresh) { + const nextResultGroupIds = new Set() + const res = groupReflections(reflections, { + groupingThreshold: currentThresh, + maxGroupSize: reflectionsCount, + maxReductionPercent: MAX_REDUCTION_PERCENTAGE + }) + const {groupedReflectionsRes} = res + const nextThresh = res.nextThresh as number | null + const spotlightGroupedReflection = groupedReflectionsRes.find( + (group) => group.oldReflectionGroupId === reflectionGroupId + ) + if (!spotlightGroupedReflection) break + for (const groupedReflectionRes of groupedReflectionsRes) { + const {reflectionGroupId, oldReflectionGroupId} = groupedReflectionRes + if ( + reflectionGroupId === spotlightGroupedReflection.reflectionGroupId && + oldReflectionGroupId !== spotlightGroupedReflection.oldReflectionGroupId + ) { + nextResultGroupIds.add(oldReflectionGroupId) + } + currentThresh = nextThresh + if (nextResultGroupIds.size > spotlightResultGroupSize) { + currentThresh = null + break + } else { + currentResultGroupIds = nextResultGroupIds + if (nextResultGroupIds.size === spotlightResultGroupSize) { + currentThresh = null + break + } + } + } + } + return ( + await dataLoader.get('retroReflectionGroups').loadMany(Array.from(currentResultGroupIds)) + ).filter(isValid) + }, + + teamInvitation: async ({id: userId}, {meetingId, teamId: inTeamId}, {authToken, dataLoader}) => { + if (!meetingId && !inTeamId) return {} + const viewerId = getUserId(authToken) + if (viewerId !== userId && !isSuperUser(authToken)) return {} + const user = (await dataLoader.get('users').load(userId))! + const {email} = user + let teamId = inTeamId + if (!teamId && meetingId) { + const meeting = await dataLoader.get('newMeetings').load(meetingId) + if (!meeting) return {meetingId} + teamId = meeting.teamId + } + const teamInvitations = teamId + ? await dataLoader.get('teamInvitationsByTeamId').load(teamId) + : null + if (!teamInvitations) return {teamId, meetingId} + const teamInvitation = teamInvitations.find((invitation) => invitation.email === email) + return {teamInvitation, teamId, meetingId} + }, + + teams: async ({id: userId}, {includeArchived}, {authToken, dataLoader}) => { + const viewerId = getUserId(authToken) + const user = (await dataLoader.get('users').load(userId))! + const activeTeamIds = + viewerId === userId || isSuperUser(authToken) + ? user.tms + : user.tms.filter((teamId: string) => authToken.tms.includes(teamId)) + const teamIds = includeArchived + ? (await dataLoader.get('teamMembersByUserId').load(userId)).map(({teamId}) => teamId) + : activeTeamIds + const teams = (await dataLoader.get('teams').loadMany(teamIds)).filter(isValid) + teams.sort((a, b) => (a.name > b.name ? 1 : -1)) + return teams + }, + + teamMember: ({id}, {teamId, userId}, {authToken, dataLoader}) => { + if (!isTeamMember(authToken, teamId)) { + const viewerId = getUserId(authToken) + standardError(new Error('Not on team'), {userId: viewerId}) + return null + } + const teamMemberId = toTeamMemberId(teamId, userId || id) + return dataLoader.get('teamMembers').loadNonNull(teamMemberId) + }, + + tms: ({id: userId, tms}, _args, {authToken}) => { + const viewerId = getUserId(authToken) + return viewerId === userId + ? tms + : tms.filter((teamId: string) => authToken.tms.includes(teamId)) + }, + + userOnTeam: async (_source, {userId}, {authToken, dataLoader}) => { + const userOnTeam = await dataLoader.get('users').load(userId) + if (!userOnTeam) { + return null + } + // const teams = new Set(userOnTeam) + const {tms} = userOnTeam + if (!authToken.tms.find((teamId) => tms.includes(teamId))) return null + return userOnTeam + }, activity: async (_source, {activityId}, {dataLoader}) => { const activity = await dataLoader.get('meetingTemplates').load(activityId) return activity || null diff --git a/packages/server/graphql/queries/archivedTasks.ts b/packages/server/graphql/queries/archivedTasks.ts deleted file mode 100644 index d5a768f0d25..00000000000 --- a/packages/server/graphql/queries/archivedTasks.ts +++ /dev/null @@ -1,75 +0,0 @@ -import {GraphQLID, GraphQLInt, GraphQLNonNull} from 'graphql' -import getRethink from '../../database/rethinkDriver' -import {RValue} from '../../database/stricterR' -import {getUserId, isTeamMember} from '../../utils/authorization' -import standardError from '../../utils/standardError' -import GraphQLISO8601Type from '../types/GraphQLISO8601Type' -import {TaskConnection} from '../types/Task' -import {GQLContext} from './../graphql' - -export default { - type: TaskConnection, - args: { - first: { - type: new GraphQLNonNull(GraphQLInt) - }, - after: { - type: GraphQLISO8601Type, - description: 'the datetime cursor' - }, - teamId: { - type: new GraphQLNonNull(GraphQLID), - description: 'The unique team ID' - } - }, - async resolve( - _source: unknown, - {first, after, teamId}: {first: number; after?: Date; teamId: string}, - {authToken}: GQLContext - ) { - const r = await getRethink() - - // AUTH - const userId = getUserId(authToken) - if (!isTeamMember(authToken, teamId)) { - standardError(new Error('Not organization lead'), {userId}) - return null - } - - // RESOLUTION - const teamMemberId = `${userId}::${teamId}` - const dbAfter = after ? new Date(after) : r.maxval - const tasks = await r - .table('Task') - // use a compound index so we can easily paginate later - .between([teamId, r.minval], [teamId, dbAfter], { - index: 'teamIdUpdatedAt' - }) - .filter((task: RValue) => - task('tags') - .contains('archived') - .and( - r.branch(task('tags').contains('private'), task('teamMemberId').eq(teamMemberId), true) - ) - ) - .orderBy(r.desc('updatedAt')) - .limit(first + 1) - .coerceTo('array') - .run() - - const nodes = tasks.slice(0, first) - const edges = nodes.map((node) => ({ - cursor: node.updatedAt, - node - })) - const firstEdge = edges[0] - return { - edges, - pageInfo: { - startCursor: firstEdge && firstEdge.cursor, - endCursor: firstEdge ? edges[edges.length - 1]!.cursor : new Date(), - hasNextPage: tasks.length > nodes.length - } - } - } -} diff --git a/packages/server/graphql/queries/archivedTasksCount.ts b/packages/server/graphql/queries/archivedTasksCount.ts deleted file mode 100644 index 9639467e805..00000000000 --- a/packages/server/graphql/queries/archivedTasksCount.ts +++ /dev/null @@ -1,44 +0,0 @@ -import {GraphQLID, GraphQLInt, GraphQLNonNull} from 'graphql' -import getRethink from '../../database/rethinkDriver' -import {RValue} from '../../database/stricterR' -import {getUserId, isTeamMember} from '../../utils/authorization' -import standardError from '../../utils/standardError' -import {GQLContext} from '../graphql' - -export default { - type: GraphQLInt, - args: { - teamId: { - type: new GraphQLNonNull(GraphQLID), - description: 'The unique team ID' - } - }, - async resolve(_source: unknown, {teamId}: {teamId: string}, {authToken}: GQLContext) { - const r = await getRethink() - const viewerId = getUserId(authToken) - - // AUTH - const userId = getUserId(authToken) - if (!isTeamMember(authToken, teamId)) { - standardError(new Error('Team not found'), {userId: viewerId}) - return 0 - } - - // RESOLUTION - const teamMemberId = `${userId}::${teamId}` - return r - .table('Task') - .between([teamId, r.minval], [teamId, r.maxval], { - index: 'teamIdUpdatedAt' - }) - .filter((task: RValue) => - task('tags') - .contains('archived') - .and( - r.branch(task('tags').contains('private'), task('teamMemberId').eq(teamMemberId), true) - ) - ) - .count() - .run() - } -} diff --git a/packages/server/graphql/queries/invoices.ts b/packages/server/graphql/queries/invoices.ts deleted file mode 100644 index 23e6b7c029d..00000000000 --- a/packages/server/graphql/queries/invoices.ts +++ /dev/null @@ -1,85 +0,0 @@ -import {GraphQLID, GraphQLInt, GraphQLNonNull} from 'graphql' -import getRethink from '../../database/rethinkDriver' -import {RDatum} from '../../database/stricterR' -import Invoice from '../../database/types/Invoice' -import {getUserId, isUserBillingLeader} from '../../utils/authorization' -import GraphQLISO8601Type from '../types/GraphQLISO8601Type' -import {InvoiceConnection} from '../types/Invoice' -import {GQLContext} from './../graphql' -import makeUpcomingInvoice from './helpers/makeUpcomingInvoice' - -export default { - type: InvoiceConnection, - args: { - first: { - type: new GraphQLNonNull(GraphQLInt) - }, - after: { - type: GraphQLISO8601Type, - description: 'the datetime cursor' - }, - orgId: { - type: new GraphQLNonNull(GraphQLID), - description: 'The id of the organization' - } - }, - async resolve( - _source: unknown, - {orgId, first, after}: {orgId: string; first: number; after?: Date}, - {authToken, dataLoader}: GQLContext - ) { - const r = await getRethink() - - // AUTH - const viewerId = getUserId(authToken) - if (!(await isUserBillingLeader(viewerId, orgId, dataLoader))) { - // standardError(new Error('Not organization lead'), {userId: viewerId}) - return null - } - - // RESOLUTION - const {stripeId} = await dataLoader.get('organizations').loadNonNull(orgId) - const dbAfter = after ? new Date(after) : r.maxval - const [tooManyInvoices, orgUsers] = await Promise.all([ - r - .table('Invoice') - .between([orgId, r.minval], [orgId, dbAfter], { - index: 'orgIdStartAt', - leftBound: 'open', - rightBound: 'closed' - }) - .filter((invoice: RDatum) => invoice('status').ne('UPCOMING').and(invoice('total').ne(0))) - // it's possible that stripe gives the same startAt to 2 invoices (the first $5 charge & the next) - // break ties based on when created. In the future, we might want to consider using the created_at provided by stripe instead of our own - .orderBy(r.desc('startAt'), r.desc('createdAt')) - .limit(first + 1) - .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 - : await makeUpcomingInvoice(org, orgUserCount, stripeId) - const extraInvoices: Invoice[] = tooManyInvoices || [] - const paginatedInvoices = after ? extraInvoices.slice(1) : extraInvoices - const allInvoices = upcomingInvoice - ? [upcomingInvoice, ...paginatedInvoices] - : paginatedInvoices - const nodes = allInvoices.slice(0, first) - const edges = nodes.map((node) => ({ - cursor: node.startAt, - node - })) - const firstEdge = edges[0] - return { - edges, - pageInfo: { - startCursor: firstEdge && firstEdge.cursor, - endCursor: firstEdge && edges[edges.length - 1]!.cursor, - hasNextPage: extraInvoices.length + (upcomingInvoice ? 1 : 0) > first - } - } - } -} as any diff --git a/packages/server/graphql/queries/newMeeting.ts b/packages/server/graphql/queries/newMeeting.ts deleted file mode 100644 index 7f70da1eb59..00000000000 --- a/packages/server/graphql/queries/newMeeting.ts +++ /dev/null @@ -1,39 +0,0 @@ -import {GraphQLID, GraphQLNonNull} from 'graphql' -import toTeamMemberId from 'parabol-client/utils/relay/toTeamMemberId' -import {getUserId, isTeamMember} from '../../utils/authorization' -import standardError from '../../utils/standardError' -import NewMeeting from '../types/NewMeeting' -import {GQLContext} from './../graphql' - -export default { - type: NewMeeting, - description: 'A previous meeting that the user was in (present or absent)', - args: { - meetingId: { - type: new GraphQLNonNull(GraphQLID), - description: 'The meeting ID' - } - }, - async resolve( - _source: unknown, - {meetingId}: {meetingId: string}, - {authToken, dataLoader}: GQLContext - ) { - const viewerId = getUserId(authToken) - const meeting = await dataLoader.get('newMeetings').load(meetingId) - if (!meeting) { - standardError(new Error('Meeting not found'), {userId: viewerId, tags: {meetingId}}) - return null - } - const {teamId} = meeting - if (!isTeamMember(authToken, teamId)) { - const meetingMemberId = toTeamMemberId(meetingId, viewerId) - const meetingMember = await dataLoader.get('meetingMembers').load(meetingMemberId) - if (!meetingMember) { - // standardError(new Error('Team not found'), {userId: viewerId, tags: {teamId}}) - return null - } - } - return meeting - } -} diff --git a/packages/server/graphql/queries/notifications.ts b/packages/server/graphql/queries/notifications.ts deleted file mode 100644 index 8bd0ff09989..00000000000 --- a/packages/server/graphql/queries/notifications.ts +++ /dev/null @@ -1,64 +0,0 @@ -import {GraphQLInt, GraphQLList, GraphQLNonNull, GraphQLObjectType} from 'graphql' -import getRethink from '../../database/rethinkDriver' -import {RDatum} from '../../database/stricterR' -import {getUserId} from '../../utils/authorization' -import {GQLContext} from '../graphql' -import GraphQLISO8601Type from '../types/GraphQLISO8601Type' -import NotificationEnum, {NotificationEnumType} from '../types/NotificationEnum' - -export default { - type: new GraphQLNonNull(new GraphQLObjectType({name: 'NotificationConnection', fields: {}})), - args: { - // currently not used - first: { - type: new GraphQLNonNull(GraphQLInt) - }, - after: { - type: GraphQLISO8601Type - }, - types: { - type: new GraphQLList(new GraphQLNonNull(NotificationEnum)) - } - }, - description: 'all the notifications for a single user', - resolve: async ( - _source: unknown, - {first, after, types}: {first: number; after: Date; types: NotificationEnumType}, - {authToken}: GQLContext - ) => { - const r = await getRethink() - // AUTH - const userId = getUserId(authToken) - const dbAfter = after || r.maxval - // RESOLUTION - // TODO consider moving the requestedFields to all queries - const nodesPlus1 = await r - .table('Notification') - .getAll(userId, {index: 'userId'}) - .orderBy(r.desc('createdAt')) - .filter((row: RDatum) => { - if (types) { - return row('createdAt') - .lt(dbAfter) - .and(r.expr(types).contains(row('type'))) - } - return row('createdAt').lt(dbAfter) - }) - .limit(first + 1) - .run() - - const nodes = nodesPlus1.slice(0, first) - const edges = nodes.map((node) => ({ - cursor: node.createdAt, - node - })) - const lastEdge = edges[edges.length - 1] - return { - edges, - pageInfo: { - endCursor: lastEdge?.cursor, - hasNextPage: nodesPlus1.length > first - } - } - } -} diff --git a/packages/server/graphql/queries/organization.ts b/packages/server/graphql/queries/organization.ts deleted file mode 100644 index bd0cacdfbef..00000000000 --- a/packages/server/graphql/queries/organization.ts +++ /dev/null @@ -1,28 +0,0 @@ -import {GraphQLID, GraphQLNonNull} from 'graphql' -import {getUserId, isSuperUser} from '../../utils/authorization' -import Organization from '../types/Organization' -import {GQLContext} from './../graphql' - -export default { - type: Organization, - args: { - orgId: { - type: new GraphQLNonNull(GraphQLID), - description: 'the orgId' - } - }, - description: 'get a single organization', - resolve: async ( - _source: unknown, - {orgId}: {orgId: string}, - {authToken, dataLoader}: GQLContext - ) => { - const viewerId = getUserId(authToken) - const [organization, viewerOrganizationUser] = await Promise.all([ - dataLoader.get('organizations').load(orgId), - dataLoader.get('organizationUsersByUserIdOrgId').load({userId: viewerId, orgId}) - ]) - if (!isSuperUser(authToken) && !viewerOrganizationUser) return null - return organization - } -} as any diff --git a/packages/server/graphql/queries/tasks.ts b/packages/server/graphql/queries/tasks.ts deleted file mode 100644 index ce47533ff7f..00000000000 --- a/packages/server/graphql/queries/tasks.ts +++ /dev/null @@ -1,147 +0,0 @@ -import { - GraphQLBoolean, - GraphQLID, - GraphQLInt, - GraphQLList, - GraphQLNonNull, - GraphQLString -} from 'graphql' -import isTaskPrivate from 'parabol-client/utils/isTaskPrivate' -import Task from '../../database/types/Task' -import {getUserId} from '../../utils/authorization' -import standardError from '../../utils/standardError' -import errorFilter from '../errorFilter' -import {DataLoaderWorker, GQLContext} from '../graphql' -import GraphQLISO8601Type from '../types/GraphQLISO8601Type' -import {TaskConnection} from '../types/Task' -import TaskStatusEnum, {TaskStatusEnumType} from '../types/TaskStatusEnum' -import connectionFromTasks from './helpers/connectionFromTasks' - -const getValidUserIds = async ( - userIds: null | string[], - viewerId: string, - validTeamIds: string[], - dataLoader: DataLoaderWorker -) => { - if (!userIds) return null - if (userIds.length === 1 && userIds[0] === viewerId) return userIds - // NOTE: this will filter out ex-teammembers. if that's a problem, we should use a different dataloader - const teamMembersByUserIds = ( - await dataLoader.get('teamMembersByUserId').loadMany(userIds) - ).filter(errorFilter) - const teamMembersOnValidTeams = teamMembersByUserIds - .flat() - .filter((teamMember) => validTeamIds.includes(teamMember.teamId)) - const teamMemberUserIds = new Set( - teamMembersOnValidTeams.map(({userId}: {userId: string}) => userId) - ) - return userIds.filter((userId) => teamMemberUserIds.has(userId)) -} - -export default { - type: new GraphQLNonNull(TaskConnection), - args: { - first: { - type: new GraphQLNonNull(GraphQLInt), - description: 'the number of tasks to return' - }, - after: { - type: GraphQLISO8601Type, - description: 'the datetime cursor' - }, - userIds: { - type: new GraphQLList(GraphQLID), - description: - 'a list of user Ids that you want tasks for. if null, will return tasks for all possible team members. An id is null if it is not assigned to anyone.' - }, - teamIds: { - type: new GraphQLList(new GraphQLNonNull(GraphQLID)), - description: - 'a list of team Ids that you want tasks for. if null, will return tasks for all possible active teams' - }, - archived: { - type: GraphQLBoolean, - description: 'true to only return archived tasks; false to return active tasks', - defaultValue: false - }, - statusFilters: { - type: new GraphQLList(new GraphQLNonNull(TaskStatusEnum)), - description: 'filter tasks by the chosen statuses' - }, - filterQuery: { - type: GraphQLString, - description: 'only return tasks which match the given filter query' - }, - includeUnassigned: { - type: GraphQLBoolean, - description: 'if true, include unassigned tasks. If false, only return assigned tasks', - defaultValue: false - } - }, - async resolve( - _source: unknown, - { - first, - after, - userIds, - teamIds, - archived, - statusFilters, - filterQuery, - includeUnassigned - }: { - first: number - after?: Date - userIds: string[] - teamIds: string[] - archived?: boolean - statusFilters: TaskStatusEnumType[] - filterQuery?: string - includeUnassigned?: boolean - }, - {authToken, dataLoader}: GQLContext - ) { - // AUTH - const viewerId = getUserId(authToken) - // VALIDATE - if (teamIds?.length > 100 || userIds?.length > 100) { - const err = new Error('Task filter is too broad') - standardError(err, { - userId: viewerId, - tags: {userIds: JSON.stringify(userIds), teamIds: JSON.stringify(teamIds)} - }) - return connectionFromTasks([], 0, err) - } - // common queries - // - give me all the tasks for a particular team (users: all, team: abc) - // - give me all the tasks for a particular user (users: 123, team: all) - // - give me all the tasks for a number of teams (users: all, team: [abc, def]) - // - give me all the tasks for a number of users (users: [123, 456], team: all) - // - give me all the tasks for a set of users & teams (users: [123, 456], team: [abc, def]) - // - give me all the tasks for all the users on all the teams (users: all, team: all) - - // if archived is true & no userId filter is provided, it should include tasks for ex-team members - // under no condition should it show tasks for archived teams - const accessibleTeamIds = authToken.tms - const validTeamIds = teamIds - ? teamIds.filter((teamId: string) => accessibleTeamIds.includes(teamId)) - : accessibleTeamIds - const validUserIds = (await getValidUserIds(userIds, viewerId, validTeamIds, dataLoader)) ?? [] - // RESOLUTION - const tasks = await dataLoader.get('userTasks').load({ - first, - after, - userIds: validUserIds, - teamIds: validTeamIds, - archived, - statusFilters, - filterQuery, - includeUnassigned - }) - const filteredTasks = tasks.filter((task: Task) => { - if (isTaskPrivate(task.tags) && task.userId !== viewerId) return false - return true - }) - return connectionFromTasks(filteredTasks, first) - } -} diff --git a/packages/server/graphql/queries/team.ts b/packages/server/graphql/queries/team.ts deleted file mode 100644 index 45c55e50764..00000000000 --- a/packages/server/graphql/queries/team.ts +++ /dev/null @@ -1,40 +0,0 @@ -import {GraphQLID, GraphQLNonNull, GraphQLResolveInfo} from 'graphql' -import {getUserId, isSuperUser, isTeamMember} from '../../utils/authorization' -import standardError from '../../utils/standardError' -import {GQLContext} from '../graphql' -import Team from '../types/Team' - -// HANDLED_OPS is a list of operations that we gracefully handle on the client, so we don't want to report them to sentry -const HANDLED_OPS = ['TeamRootQuery', 'TeamContainerQuery'] - -export default { - type: Team, - description: 'A query for a team', - args: { - teamId: { - type: new GraphQLNonNull(GraphQLID), - description: 'The team ID for the desired team' - } - }, - async resolve( - _source: unknown, - {teamId}: {teamId: string}, - {authToken, dataLoader}: GQLContext, - {operation}: GraphQLResolveInfo - ) { - const team = await dataLoader.get('teams').loadNonNull(teamId) - const {orgId} = team - const viewerId = getUserId(authToken) - const {role} = - (await dataLoader.get('organizationUsersByUserIdOrgId').load({userId: viewerId, orgId})) ?? {} - const isOrgAdmin = role === 'ORG_ADMIN' - if (!isOrgAdmin && !isTeamMember(authToken, teamId) && !isSuperUser(authToken)) { - const viewerId = getUserId(authToken) - if (!HANDLED_OPS.includes(operation?.name?.value ?? '')) { - standardError(new Error('Team not found'), {userId: viewerId}) - } - return null - } - return dataLoader.get('teams').load(teamId) - } -} diff --git a/packages/server/graphql/rootTypes.ts b/packages/server/graphql/rootTypes.ts index c06d2d12ce5..d2c9f7cfe3e 100644 --- a/packages/server/graphql/rootTypes.ts +++ b/packages/server/graphql/rootTypes.ts @@ -5,11 +5,6 @@ import IntegrationProviderWebhook from './types/IntegrationProviderWebhook' import JiraDimensionField from './types/JiraDimensionField' import RenamePokerTemplatePayload from './types/RenamePokerTemplatePayload' import SetMeetingSettingsPayload from './types/SetMeetingSettingsPayload' -import SuggestedActionCreateNewTeam from './types/SuggestedActionCreateNewTeam' -import SuggestedActionInviteYourTeam from './types/SuggestedActionInviteYourTeam' -import SuggestedActionTryActionMeeting from './types/SuggestedActionTryActionMeeting' -import SuggestedActionTryRetroMeeting from './types/SuggestedActionTryRetroMeeting' -import SuggestedActionTryTheDemo from './types/SuggestedActionTryTheDemo' import TimelineEventCompletedActionMeeting from './types/TimelineEventCompletedActionMeeting' import TimelineEventCompletedRetroMeeting from './types/TimelineEventCompletedRetroMeeting' import TimelineEventJoinedParabol from './types/TimelineEventJoinedParabol' @@ -22,11 +17,6 @@ const rootTypes = [ IntegrationProviderOAuth2, IntegrationProviderWebhook, SetMeetingSettingsPayload, - SuggestedActionInviteYourTeam, - SuggestedActionTryRetroMeeting, - SuggestedActionTryActionMeeting, - SuggestedActionCreateNewTeam, - SuggestedActionTryTheDemo, TimelineEventTeamCreated, TimelineEventJoinedParabol, TimelineEventCompletedRetroMeeting, diff --git a/packages/server/graphql/types/SuggestedAction.ts b/packages/server/graphql/types/SuggestedAction.ts deleted file mode 100644 index 8147dd02779..00000000000 --- a/packages/server/graphql/types/SuggestedAction.ts +++ /dev/null @@ -1,48 +0,0 @@ -import {GraphQLFloat, GraphQLID, GraphQLInterfaceType, GraphQLNonNull} from 'graphql' -import {GQLContext} from './../graphql' -import GraphQLISO8601Type from './GraphQLISO8601Type' -import SuggestedActionTypeEnum from './SuggestedActionTypeEnum' -import User from './User' - -export const suggestedActionInterfaceFields = () => ({ - id: { - type: new GraphQLNonNull(GraphQLID), - description: 'shortid' - }, - createdAt: { - type: new GraphQLNonNull(GraphQLISO8601Type), - description: '* The timestamp the action was created at' - }, - priority: { - type: GraphQLFloat, - description: - 'The priority of the suggested action compared to other suggested actions (smaller number is higher priority)' - }, - removedAt: { - type: new GraphQLNonNull(GraphQLISO8601Type), - description: '* The timestamp the action was removed at' - }, - type: { - type: new GraphQLNonNull(SuggestedActionTypeEnum), - description: 'The specific type of suggested action' - }, - userId: { - type: new GraphQLNonNull(GraphQLID), - description: '* The userId this action is for' - }, - user: { - type: new GraphQLNonNull(User), - description: 'The user than can see this event', - resolve: ({userId}: {userId: string}, _args: unknown, {dataLoader}: GQLContext) => { - return dataLoader.get('users').load(userId) - } - } -}) - -const SuggestedAction = new GraphQLInterfaceType({ - name: 'SuggestedAction', - description: 'A past event that is important to the viewer', - fields: suggestedActionInterfaceFields -}) - -export default SuggestedAction diff --git a/packages/server/graphql/types/SuggestedActionCreateNewTeam.ts b/packages/server/graphql/types/SuggestedActionCreateNewTeam.ts deleted file mode 100644 index fd465cde551..00000000000 --- a/packages/server/graphql/types/SuggestedActionCreateNewTeam.ts +++ /dev/null @@ -1,16 +0,0 @@ -import {GraphQLObjectType} from 'graphql' -import {GQLContext} from '../graphql' -import SuggestedAction, {suggestedActionInterfaceFields} from './SuggestedAction' -import {TSuggestedActionTypeEnum} from './SuggestedActionTypeEnum' - -const SuggestedActionCreateNewTeam = new GraphQLObjectType({ - name: 'SuggestedActionCreateNewTeam', - description: 'a suggestion to try a retro with your team', - interfaces: () => [SuggestedAction], - isTypeOf: ({type}: {type: TSuggestedActionTypeEnum}) => type === 'createNewTeam', - fields: () => ({ - ...suggestedActionInterfaceFields() - }) -}) - -export default SuggestedActionCreateNewTeam diff --git a/packages/server/graphql/types/SuggestedActionInviteYourTeam.ts b/packages/server/graphql/types/SuggestedActionInviteYourTeam.ts deleted file mode 100644 index 66906732727..00000000000 --- a/packages/server/graphql/types/SuggestedActionInviteYourTeam.ts +++ /dev/null @@ -1,27 +0,0 @@ -import {GraphQLID, GraphQLNonNull, GraphQLObjectType} from 'graphql' -import {GQLContext} from '../graphql' -import {resolveTeam} from '../resolvers' -import SuggestedAction, {suggestedActionInterfaceFields} from './SuggestedAction' -import {TSuggestedActionTypeEnum} from './SuggestedActionTypeEnum' -import Team from './Team' - -const SuggestedActionInviteYourTeam = new GraphQLObjectType({ - name: 'SuggestedActionInviteYourTeam', - description: 'a suggestion to invite others to your team', - interfaces: () => [SuggestedAction], - isTypeOf: ({type}: {type: TSuggestedActionTypeEnum}) => type === 'inviteYourTeam', - fields: () => ({ - ...suggestedActionInterfaceFields(), - teamId: { - type: new GraphQLNonNull(GraphQLID), - description: 'The teamId that we suggest you should invite people to' - }, - team: { - type: new GraphQLNonNull(Team), - description: 'The team you should invite people to', - resolve: resolveTeam - } - }) -}) - -export default SuggestedActionInviteYourTeam diff --git a/packages/server/graphql/types/SuggestedActionTryActionMeeting.ts b/packages/server/graphql/types/SuggestedActionTryActionMeeting.ts deleted file mode 100644 index 1ac8d2d68ee..00000000000 --- a/packages/server/graphql/types/SuggestedActionTryActionMeeting.ts +++ /dev/null @@ -1,27 +0,0 @@ -import {GraphQLID, GraphQLNonNull, GraphQLObjectType} from 'graphql' -import {GQLContext} from '../graphql' -import {resolveTeam} from '../resolvers' -import SuggestedAction, {suggestedActionInterfaceFields} from './SuggestedAction' -import {TSuggestedActionTypeEnum} from './SuggestedActionTypeEnum' -import Team from './Team' - -const SuggestedActionTryActionMeeting = new GraphQLObjectType({ - name: 'SuggestedActionTryActionMeeting', - description: 'a suggestion to try a retro with your team', - interfaces: () => [SuggestedAction], - isTypeOf: ({type}: {type: TSuggestedActionTypeEnum}) => type === 'tryActionMeeting', - fields: () => ({ - ...suggestedActionInterfaceFields(), - teamId: { - type: new GraphQLNonNull(GraphQLID), - description: 'fk' - }, - team: { - type: new GraphQLNonNull(Team), - description: 'The team you should run an action meeting with', - resolve: resolveTeam - } - }) -}) - -export default SuggestedActionTryActionMeeting diff --git a/packages/server/graphql/types/SuggestedActionTryRetroMeeting.ts b/packages/server/graphql/types/SuggestedActionTryRetroMeeting.ts deleted file mode 100644 index b4f6ba39e9a..00000000000 --- a/packages/server/graphql/types/SuggestedActionTryRetroMeeting.ts +++ /dev/null @@ -1,27 +0,0 @@ -import {GraphQLID, GraphQLNonNull, GraphQLObjectType} from 'graphql' -import {GQLContext} from '../graphql' -import {resolveTeam} from '../resolvers' -import SuggestedAction, {suggestedActionInterfaceFields} from './SuggestedAction' -import {TSuggestedActionTypeEnum} from './SuggestedActionTypeEnum' -import Team from './Team' - -const SuggestedActionTryRetroMeeting = new GraphQLObjectType({ - name: 'SuggestedActionTryRetroMeeting', - description: 'a suggestion to try a retro with your team', - interfaces: () => [SuggestedAction], - isTypeOf: ({type}: {type: TSuggestedActionTypeEnum}) => type === 'tryRetroMeeting', - fields: () => ({ - ...suggestedActionInterfaceFields(), - teamId: { - type: new GraphQLNonNull(GraphQLID), - description: 'fk' - }, - team: { - type: new GraphQLNonNull(Team), - description: 'The team you should run a retro with', - resolve: resolveTeam - } - }) -}) - -export default SuggestedActionTryRetroMeeting diff --git a/packages/server/graphql/types/SuggestedActionTryTheDemo.ts b/packages/server/graphql/types/SuggestedActionTryTheDemo.ts deleted file mode 100644 index 38b8b250dd1..00000000000 --- a/packages/server/graphql/types/SuggestedActionTryTheDemo.ts +++ /dev/null @@ -1,17 +0,0 @@ -import {GraphQLObjectType} from 'graphql' -import {GQLContext} from '../graphql' -import SuggestedAction, {suggestedActionInterfaceFields} from './SuggestedAction' -import {TSuggestedActionTypeEnum} from './SuggestedActionTypeEnum' - -const SuggestedActionTryTheDemo = new GraphQLObjectType({ - name: 'SuggestedActionTryTheDemo', - description: 'a suggestion to invite others to your team', - interfaces: () => [SuggestedAction], - isTypeOf: ({type}: {type: TSuggestedActionTypeEnum}) => type === 'tryTheDemo', - - fields: () => ({ - ...suggestedActionInterfaceFields() - }) -}) - -export default SuggestedActionTryTheDemo diff --git a/packages/server/graphql/types/SuggestedActionTypeEnum.ts b/packages/server/graphql/types/SuggestedActionTypeEnum.ts deleted file mode 100644 index 69816873635..00000000000 --- a/packages/server/graphql/types/SuggestedActionTypeEnum.ts +++ /dev/null @@ -1,19 +0,0 @@ -import {GraphQLEnumType} from 'graphql' - -const values = { - inviteYourTeam: {}, - tryTheDemo: {}, - tryRetroMeeting: {}, - createNewTeam: {}, - tryActionMeeting: {} -} as const - -export type TSuggestedActionTypeEnum = keyof typeof values - -const SuggestedActionTypeEnum = new GraphQLEnumType({ - name: 'SuggestedActionTypeEnum', - description: 'The specific type of the suggested action', - values -}) - -export default SuggestedActionTypeEnum diff --git a/packages/server/graphql/types/User.ts b/packages/server/graphql/types/User.ts index 292a6eab123..734d31514ec 100644 --- a/packages/server/graphql/types/User.ts +++ b/packages/server/graphql/types/User.ts @@ -1,668 +1,8 @@ -import { - GraphQLBoolean, - GraphQLID, - GraphQLInt, - GraphQLList, - GraphQLNonNull, - GraphQLObjectType, - GraphQLString -} from 'graphql' -import MeetingMemberId from 'parabol-client/shared/gqlIds/MeetingMemberId' -import toTeamMemberId from 'parabol-client/utils/relay/toTeamMemberId' -import { - AUTO_GROUPING_THRESHOLD, - MAX_REDUCTION_PERCENTAGE, - MAX_RESULT_GROUP_SIZE -} from '../../../client/utils/constants' -import groupReflections from '../../../client/utils/smartGroup/groupReflections' -import MeetingMemberType from '../../database/types/MeetingMember' -import SuggestedActionType from '../../database/types/SuggestedAction' -import getKysely from '../../postgres/getKysely' -import {getUserId, isSuperUser, isTeamMember} from '../../utils/authorization' -import getMonthlyStreak from '../../utils/getMonthlyStreak' -import getRedis from '../../utils/getRedis' -import standardError from '../../utils/standardError' -import {DataLoaderWorker, GQLContext} from '../graphql' -import isValid from '../isValid' -import invoices from '../queries/invoices' -import organization from '../queries/organization' -import AuthIdentity from './AuthIdentity' -import Discussion from './Discussion' -import GraphQLEmailType from './GraphQLEmailType' -import GraphQLISO8601Type from './GraphQLISO8601Type' -import MeetingMember from './MeetingMember' -import NewFeatureBroadcast from './NewFeatureBroadcast' -import Organization from './Organization' -import OrganizationUser from './OrganizationUser' -import RetroReflectionGroup from './RetroReflectionGroup' -import SuggestedAction from './SuggestedAction' -import Team from './Team' -import TeamInvitationPayload from './TeamInvitationPayload' -import TeamMember from './TeamMember' -import {TimelineEventConnection} from './TimelineEvent' -import TimelineEventTypeEnum from './TimelineEventTypeEnum' +import {GraphQLObjectType} from 'graphql' -const User: GraphQLObjectType = new GraphQLObjectType({ +const User = new GraphQLObjectType({ name: 'User', - description: 'The user account profile', - fields: () => ({ - id: { - type: new GraphQLNonNull(GraphQLID), - description: 'The userId provided by us' - }, - pseudoId: { - type: GraphQLString, - description: 'The optional pseudoId for the user' - }, - archivedTasks: require('../queries/archivedTasks').default, - archivedTasksCount: require('../queries/archivedTasksCount').default, - createdAt: { - type: new GraphQLNonNull(GraphQLISO8601Type), - description: 'The timestamp the user was created', - resolve: ({createdAt}) => createdAt || new Date('2016-06-01') - }, - email: { - type: new GraphQLNonNull(GraphQLEmailType), - description: 'The user email' - }, - identities: { - type: new GraphQLList(AuthIdentity), - description: `An array of objects with information about the user's identities. - More than one will exists in case accounts are linked` - }, - inactive: { - type: GraphQLBoolean, - description: - 'true if the user is not currently being billed for service. removed on every websocket handshake' - }, - invoices, - isAnyBillingLeader: { - type: new GraphQLNonNull(GraphQLBoolean), - description: 'true if the user is a billing leader on any organization, else false', - resolve: async ({id: userId}: {id: string}, _args: unknown, {dataLoader}: GQLContext) => { - const organizationUsers = await dataLoader.get('organizationUsersByUserId').load(userId) - return organizationUsers.some( - (organizationUser) => - organizationUser.role === 'BILLING_LEADER' || organizationUser.role === 'ORG_ADMIN' - ) - } - }, - isConnected: { - type: GraphQLBoolean, - description: 'true if the user is currently online', - resolve: async ({id: userId}: {id: string}) => { - const redis = getRedis() - const connectedSocketsCount = await redis.llen(`presence:${userId}`) - return connectedSocketsCount > 0 - } - }, - isPatient0: { - type: new GraphQLNonNull(GraphQLBoolean), - description: 'true if the user is the first to sign up from their domain, else false' - }, - isPatientZero: { - type: new GraphQLNonNull(GraphQLBoolean), - description: 'true if the user is the first to sign up from their domain, else false', - deprecationReason: 'Use isPatient0 instead', - resolve: ({isPatient0}) => isPatient0 - }, - reasonRemoved: { - type: GraphQLString, - description: 'the reason the user account was removed' - }, - isRemoved: { - type: new GraphQLNonNull(GraphQLBoolean), - description: 'true if the user was removed from parabol, else false', - resolve: ({isRemoved}: {isRemoved: boolean}) => !!isRemoved - }, - isWatched: { - type: new GraphQLNonNull(GraphQLBoolean), - description: 'true if all user sessions are being recorded, else false', - resolve: ({isWatched}: {isWatched: boolean}) => !!isWatched - }, - lastMetAt: { - type: GraphQLISO8601Type, - description: 'the endedAt timestamp of the most recent meeting they were a member of', - resolve: async ({id: userId}: {id: string}, _args: unknown, {dataLoader}: GQLContext) => { - const meetingMembers = await dataLoader.get('meetingMembersByUserId').load(userId) - const lastMetAt = Math.max( - 0, - ...meetingMembers.map(({updatedAt}: MeetingMemberType) => updatedAt.getTime()) - ) - return lastMetAt ? new Date(lastMetAt) : null - } - }, - meetingCount: { - type: new GraphQLNonNull(GraphQLInt), - description: 'The number of meetings the user has attended', - resolve: async ({id: userId}: {id: string}, _args: unknown, {dataLoader}: GQLContext) => { - const meetingMembers = await dataLoader.get('meetingMembersByUserId').load(userId) - return meetingMembers.length - } - }, - monthlyStreakMax: { - type: new GraphQLNonNull(GraphQLInt), - description: 'The largest number of consecutive months the user has checked into a meeting', - resolve: async ({id: userId}: {id: string}, _args: unknown, {dataLoader}: GQLContext) => { - const meetingMembers = await dataLoader.get('meetingMembersByUserId').load(userId) - const meetingDates = meetingMembers - .map(({updatedAt}: MeetingMemberType) => updatedAt.getTime()) - .sort((a, b) => (a < b ? 1 : -1)) - - return getMonthlyStreak(meetingDates) - } - }, - monthlyStreakCurrent: { - type: new GraphQLNonNull(GraphQLInt), - description: - 'The number of consecutive 30-day intervals that the user has checked into a meeting as of this moment', - resolve: async ({id: userId}: {id: string}, _args: unknown, {dataLoader}: GQLContext) => { - const meetingMembers = await dataLoader.get('meetingMembersByUserId').load(userId) - const meetingDates = meetingMembers - .map(({updatedAt}: MeetingMemberType) => updatedAt.getTime()) - .sort((a, b) => (a < b ? 1 : -1)) - return getMonthlyStreak(meetingDates, true) - } - }, - suggestedActions: { - type: new GraphQLNonNull(new GraphQLList(new GraphQLNonNull(SuggestedAction))), - description: 'the most important actions for the user to perform', - resolve: async ( - {id: userId}: {id: string}, - _args: unknown, - {dataLoader, authToken}: GQLContext - ) => { - const viewerId = getUserId(authToken) - if (viewerId !== userId) return [] - const suggestedActions = await dataLoader.get('suggestedActionsByUserId').load(userId) - suggestedActions.sort((a: SuggestedActionType, b: SuggestedActionType) => - a.priority! < b.priority! ? -1 : 1 - ) - return suggestedActions - } - }, - payLaterClickCount: { - type: new GraphQLNonNull(GraphQLInt), - description: 'the number of times the user clicked pay later', - resolve: ({payLaterClickCount}: {payLaterClickCount: boolean}) => payLaterClickCount || 0 - }, - timeline: { - type: new GraphQLNonNull(TimelineEventConnection), - description: 'The timeline of important events for the viewer', - args: { - after: { - type: GraphQLISO8601Type, - description: 'the datetime cursor' - }, - first: { - type: new GraphQLNonNull(GraphQLInt), - description: 'the number of timeline events to return' - }, - teamIds: { - type: new GraphQLList(new GraphQLNonNull(GraphQLID)), - description: - 'a list of team Ids that you want timeline events for. if null, will return timeline events for all possible active teams' - }, - eventTypes: { - type: new GraphQLList(new GraphQLNonNull(TimelineEventTypeEnum)) - } - }, - resolve: async ( - {id}: {id: string}, - {after, first, teamIds, eventTypes}, - {authToken, dataLoader}: GQLContext - ) => { - const viewerId = getUserId(authToken) - - // VALIDATE - if (teamIds?.length > 100) { - const error = new Error('Timeline filter is too broad') - standardError(error, { - userId: viewerId, - tags: {teamIds: JSON.stringify(teamIds)} - }) - return { - error, - pageInfo: { - startCursor: after, - endCursor: after, - hasNextPage: false - }, - edges: [] - } - } - const userTeamMembers = await dataLoader.get('teamMembersByUserId').load(viewerId) - const accessibleTeamIds = userTeamMembers.map(({teamId}) => teamId) - const validTeamIds = teamIds - ? teamIds.filter((teamId: string) => accessibleTeamIds.includes(teamId)) - : accessibleTeamIds - - if (viewerId !== id && !isSuperUser(authToken)) return null - 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((eb) => eb.between('createdAt', minVal, dbAfter)) - .where('isActive', '=', true) - .where('teamId', 'in', validTeamIds) - .$if(!!eventTypes, (db) => db.where('type', 'in', eventTypes)) - .orderBy('createdAt', 'desc') - .limit(first + 1) - .execute() - const edges = events.slice(0, first).map((node) => ({ - cursor: node.createdAt, - node - })) - const [firstEdge] = edges - return { - edges, - pageInfo: { - startCursor: firstEdge ? firstEdge.cursor : null, - // FIXME: the PageInfo type should be a GraphQLISO8601 type, but fixing that requires more work - // because the type is shared all over so we'll have to verify that the change doesn't break anything - endCursor: firstEdge ? new Date(edges[edges.length - 1]!.cursor).toJSON() : null, - hasNextPage: events.length > edges.length - } - } - } - }, - discussion: { - type: Discussion, - args: { - id: { - type: new GraphQLNonNull(GraphQLID), - description: 'The ID of the discussion' - } - }, - description: 'the comments and tasks created from the discussion', - resolve: async (_source: unknown, {id}, {authToken, dataLoader}: GQLContext) => { - const discussion = await dataLoader.get('discussions').load(id) - if (!discussion) return null - const {teamId} = discussion - if (!isTeamMember(authToken, teamId)) { - return null - } - return discussion - } - }, - newFeatureId: { - type: GraphQLID, - description: 'the ID of the newest feature, null if the user has dismissed it' - }, - newFeature: { - type: NewFeatureBroadcast, - description: 'The new feature released by Parabol. null if the user already hid it', - resolve: ( - {newFeatureId}: {newFeatureId: string}, - _args: unknown, - {dataLoader}: GQLContext - ) => { - return newFeatureId ? dataLoader.get('newFeatures').load(newFeatureId) : null - } - }, - preferredName: { - type: new GraphQLNonNull(GraphQLString), - description: 'The application-specific name, defaults to email before the tld', - resolve: ({preferredName, name}: {preferredName: string; name: string}) => { - return preferredName || name - } - }, - lastSeenAt: { - type: new GraphQLNonNull(GraphQLISO8601Type), - description: 'The last day the user connected via websocket or navigated to a common area' - }, - lastSeenAtURLs: { - type: new GraphQLList(GraphQLString), - description: - 'The paths that the user is currently visiting. This is null if the user is not currently online. A URL can also be null if the socket is not in a meeting, e.g. on the timeline.', - resolve: async ({id: userId}: {id: string}) => { - const redis = getRedis() - const userPresence = await redis.lrange(`presence:${userId}`, 0, -1) - if (!userPresence || userPresence.length === 0) return null - return userPresence.map((socket) => JSON.parse(socket).lastSeenAtURL) - } - }, - meetingMember: { - type: MeetingMember, - description: - 'The meeting member associated with this user, if a meeting is currently in progress', - args: { - meetingId: { - type: new GraphQLNonNull(GraphQLID), - description: 'The specific meeting ID' - } - }, - resolve: async ( - {id: userId}: {id: string}, - {meetingId}, - {dataLoader}: {dataLoader: DataLoaderWorker} - ) => { - const meetingMemberId = toTeamMemberId(meetingId, userId) - return meetingId ? dataLoader.get('meetingMembers').load(meetingMemberId) : undefined - } - }, - meeting: require('../queries/newMeeting').default, - newMeeting: require('../queries/newMeeting').default, // deprecated - notifications: require('../queries/notifications').default, - organization, - organizationUser: { - description: 'The connection between a user and an organization', - type: OrganizationUser, - args: { - orgId: { - type: new GraphQLNonNull(GraphQLID), - description: 'the orgId' - } - }, - resolve: async ({id: userId}: {id: string}, {orgId}, {authToken, dataLoader}: GQLContext) => { - const viewerId = getUserId(authToken) - const [viewerOrganizationUser, userOrganizationUser] = await Promise.all([ - dataLoader.get('organizationUsersByUserIdOrgId').load({userId: viewerId, orgId}), - dataLoader.get('organizationUsersByUserIdOrgId').load({userId, orgId}) - ]) - if (viewerOrganizationUser || isSuperUser(authToken)) return userOrganizationUser - return null - } - }, - organizationUsers: { - description: 'A single user that is connected to a single organization', - type: new GraphQLNonNull(new GraphQLList(new GraphQLNonNull(OrganizationUser))), - resolve: async ( - {id: userId}: {id: string}, - _args: unknown, - {authToken, dataLoader}: GQLContext - ) => { - const viewerId = getUserId(authToken) - const organizationUsers = await dataLoader.get('organizationUsersByUserId').load(userId) - 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}) => orgId) - return organizationUsers.filter((organizationUser) => - viewerOrgIds.includes(organizationUser.orgId) - ) - } - }, - organizations: { - description: 'Get the list of all organizations a user belongs to', - type: new GraphQLNonNull(new GraphQLList(new GraphQLNonNull(Organization))), - async resolve( - {id: userId}: {id: string}, - _args: unknown, - {authToken, dataLoader}: GQLContext - ) { - const organizationUsers = await dataLoader.get('organizationUsersByUserId').load(userId) - const orgIds = organizationUsers.map(({orgId}) => orgId) - const organizations = (await dataLoader.get('organizations').loadMany(orgIds)).filter( - isValid - ) - organizations.sort((a, b) => (a.name > b.name ? 1 : -1)) - const viewerId = getUserId(authToken) - if (viewerId === userId || isSuperUser(authToken)) { - return organizations - } - const viewerOrganizationUsers = await dataLoader - .get('organizationUsersByUserId') - .load(viewerId) - const viewerOrgIds = viewerOrganizationUsers.map(({orgId}) => orgId) - return organizations.filter((organization) => viewerOrgIds.includes(organization.id)) - } - }, - overLimitCopy: { - description: - 'a string with message stating that the user is over the free tier limit, else null', - type: GraphQLString, - resolve: async ( - {id: userId, overLimitCopy}: {id: string; overLimitCopy: string}, - _args: unknown, - {dataLoader}: GQLContext - ) => { - const organizationUsers = await dataLoader.get('organizationUsersByUserId').load(userId) - const isAnyMemberOfPaidOrg = organizationUsers.some( - (organizationUser) => organizationUser.tier !== 'starter' - ) - if (isAnyMemberOfPaidOrg) return null - return overLimitCopy - } - }, - sendSummaryEmail: { - type: new GraphQLNonNull(GraphQLBoolean), - description: 'Whether the user should receive a meeting summary email' - }, - similarReflectionGroups: { - type: new GraphQLNonNull(new GraphQLList(new GraphQLNonNull(RetroReflectionGroup))), - description: - 'The reflection groups that are similar to the selected reflection in the Spotlight', - args: { - reflectionGroupId: { - type: new GraphQLNonNull(GraphQLID), - description: 'The id of the selected reflection group in the Spotlight' - }, - searchQuery: { - type: new GraphQLNonNull(GraphQLString), - description: 'Only return reflection groups that match the search query' - } - }, - resolve: async ( - {id: userId}: {id: string}, - {reflectionGroupId, searchQuery: rawSearchQuery}, - {dataLoader}: GQLContext - ) => { - const searchQuery = rawSearchQuery.toLowerCase().trim() - const retroReflectionGroup = await dataLoader - .get('retroReflectionGroups') - .load(reflectionGroupId) - if (!retroReflectionGroup) { - return standardError(new Error('Invalid reflection id'), {userId}) - } - const {meetingId} = retroReflectionGroup - const meetingMemberId = MeetingMemberId.join(meetingId, userId) - const [viewerMeetingMember, reflections] = await Promise.all([ - dataLoader.get('meetingMembers').load(meetingMemberId), - dataLoader.get('retroReflectionsByMeetingId').load(meetingId) - ]) - if (!viewerMeetingMember) { - return standardError(new Error('Not on team'), {userId}) - } - - if (searchQuery !== '') { - const matchedReflections = reflections.filter(({plaintextContent}) => - plaintextContent.toLowerCase().includes(searchQuery) - ) - const relatedReflections = matchedReflections.filter( - ({reflectionGroupId: groupId}) => groupId !== reflectionGroupId - ) - const relatedGroupIds = [ - ...new Set(relatedReflections.map(({reflectionGroupId}) => reflectionGroupId)) - ].slice(0, MAX_RESULT_GROUP_SIZE) - return (await dataLoader.get('retroReflectionGroups').loadMany(relatedGroupIds)).filter( - isValid - ) - } - - const reflectionsCount = reflections.length - const spotlightResultGroupSize = Math.min(reflectionsCount - 1, MAX_RESULT_GROUP_SIZE) - let currentResultGroupIds = new Set() - let currentThresh: number | null = AUTO_GROUPING_THRESHOLD - while (currentThresh) { - const nextResultGroupIds = new Set() - const res = groupReflections(reflections, { - groupingThreshold: currentThresh, - maxGroupSize: reflectionsCount, - maxReductionPercent: MAX_REDUCTION_PERCENTAGE - }) - const {groupedReflectionsRes} = res - const nextThresh = res.nextThresh as number | null - const spotlightGroupedReflection = groupedReflectionsRes.find( - (group) => group.oldReflectionGroupId === reflectionGroupId - ) - if (!spotlightGroupedReflection) break - for (const groupedReflectionRes of groupedReflectionsRes) { - const {reflectionGroupId, oldReflectionGroupId} = groupedReflectionRes - if ( - reflectionGroupId === spotlightGroupedReflection.reflectionGroupId && - oldReflectionGroupId !== spotlightGroupedReflection.oldReflectionGroupId - ) { - nextResultGroupIds.add(oldReflectionGroupId) - } - currentThresh = nextThresh - if (nextResultGroupIds.size > spotlightResultGroupSize) { - currentThresh = null - break - } else { - currentResultGroupIds = nextResultGroupIds - if (nextResultGroupIds.size === spotlightResultGroupSize) { - currentThresh = null - break - } - } - } - } - return ( - await dataLoader.get('retroReflectionGroups').loadMany(Array.from(currentResultGroupIds)) - ).filter(isValid) - } - }, - tasks: require('../queries/tasks').default, - team: require('../queries/team').default, - teamInvitation: { - type: new GraphQLNonNull(TeamInvitationPayload), - description: 'The invitation sent to the user, even if it was sent before they were a user', - args: { - meetingId: { - type: GraphQLID, - description: - 'The meetingId to check for the invitation, if teamId not available (e.g. on a meeting route)' - }, - teamId: { - type: GraphQLID, - description: 'The teamId to check for the invitation' - } - }, - resolve: async ( - {id: userId}: {id: string}, - {meetingId, teamId: inTeamId}, - {authToken, dataLoader}: GQLContext - ) => { - if (!meetingId && !inTeamId) return {} - const viewerId = getUserId(authToken) - if (viewerId !== userId && !isSuperUser(authToken)) return {} - const user = (await dataLoader.get('users').load(userId))! - const {email} = user - let teamId = inTeamId - if (!teamId && meetingId) { - const meeting = await dataLoader.get('newMeetings').load(meetingId) - if (!meeting) return {meetingId} - teamId = meeting.teamId - } - const teamInvitations = teamId - ? await dataLoader.get('teamInvitationsByTeamId').load(teamId) - : null - if (!teamInvitations) return {teamId, meetingId} - const teamInvitation = teamInvitations.find((invitation) => invitation.email === email) - return {teamInvitation, teamId, meetingId} - } - }, - teams: { - type: new GraphQLNonNull(new GraphQLList(new GraphQLNonNull(Team))), - description: 'all the teams the user is on that the viewer can see.', - args: { - includeArchived: { - type: GraphQLBoolean, - defaultValue: false, - description: - 'If true, returns archived teams as well; otherwise only return active teams. Default to false.' - } - }, - resolve: async ( - {id: userId}: {id: string}, - {includeArchived}, - {authToken, dataLoader}: GQLContext - ) => { - const viewerId = getUserId(authToken) - const user = (await dataLoader.get('users').load(userId))! - const activeTeamIds = - viewerId === userId || isSuperUser(authToken) - ? user.tms - : user.tms.filter((teamId: string) => authToken.tms.includes(teamId)) - const teamIds = includeArchived - ? (await dataLoader.get('teamMembersByUserId').load(userId)).map(({teamId}) => teamId) - : activeTeamIds - const teams = (await dataLoader.get('teams').loadMany(teamIds)).filter(isValid) - teams.sort((a, b) => (a.name > b.name ? 1 : -1)) - return teams - } - }, - teamMember: { - type: TeamMember, - description: 'The team member associated with this user', - args: { - teamId: { - type: new GraphQLNonNull(GraphQLID), - description: 'The team the user is on' - }, - userId: { - type: GraphQLID, - description: - 'If null, defaults to the team member for this user. Else, will grab the team member. Returns null if not on team.' - } - }, - resolve: ({id}: {id: string}, {teamId, userId}, {authToken, dataLoader}: GQLContext) => { - if (!isTeamMember(authToken, teamId)) { - const viewerId = getUserId(authToken) - standardError(new Error('Not on team'), {userId: viewerId}) - return null - } - const teamMemberId = toTeamMemberId(teamId, userId || id) - return dataLoader.get('teamMembers').load(teamMemberId) - } - }, - tms: { - type: new GraphQLNonNull(new GraphQLList(new GraphQLNonNull(GraphQLID))), - description: 'all the teams the user is a part of that the viewer can see', - resolve: ( - {id: userId, tms}: {id: string; tms: string[]}, - _args: unknown, - {authToken}: GQLContext - ) => { - const viewerId = getUserId(authToken) - return viewerId === userId - ? tms - : tms.filter((teamId: string) => authToken.tms.includes(teamId)) - } - }, - updatedAt: { - type: GraphQLISO8601Type, - description: 'The timestamp the user was last updated' - }, - userOnTeam: { - type: User, - args: { - userId: { - type: new GraphQLNonNull(GraphQLID), - description: 'The other user' - } - }, - resolve: async (_source: unknown, {userId}, {authToken, dataLoader}: GQLContext) => { - const userOnTeam = await dataLoader.get('users').load(userId) - if (!userOnTeam) { - return standardError(new Error('Not on team'), {userId}) - } - // const teams = new Set(userOnTeam) - const {tms} = userOnTeam - if (!authToken.tms.find((teamId) => tms.includes(teamId))) return null - return userOnTeam - } - } - }) + fields: {} }) export default User diff --git a/packages/server/postgres/migrations/1722637208553_SuggestAction-phase2.ts b/packages/server/postgres/migrations/1722637208553_SuggestAction-phase2.ts new file mode 100644 index 00000000000..7414c33de7e --- /dev/null +++ b/packages/server/postgres/migrations/1722637208553_SuggestAction-phase2.ts @@ -0,0 +1,100 @@ +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('SuggestedAction') + .indexCreate('createdAtId', (row: any) => [row('createdAt'), row('id')]) + .run() + await r.table('SuggestedAction').indexWait().run() + } catch { + // index already exists + } + console.log('Adding index complete') + const MAX_PG_PARAMS = 65545 + const PG_COLS = ['id', 'createdAt', 'priority', 'removedAt', 'type', 'teamId', 'userId'] as const + type SuggestedAction = { + [K in (typeof PG_COLS)[number]]: any + } + const BATCH_SIZE = Math.trunc(MAX_PG_PARAMS / PG_COLS.length) + + let curcreatedAt = r.minval + let curId = r.minval + for (let i = 0; i < 1e6; i++) { + console.log('inserting row', i * BATCH_SIZE, curcreatedAt, curId) + const rawRowsToInsert = (await r + .table('SuggestedAction') + .between([curcreatedAt, curId], [r.maxval, r.maxval], { + index: 'createdAtId', + leftBound: 'open', + rightBound: 'closed' + }) + .orderBy({index: 'createdAtId'}) + .limit(BATCH_SIZE) + .pluck(...PG_COLS) + .run()) as SuggestedAction[] + + const rowsToInsert = rawRowsToInsert.map((row) => ({ + ...row + })) + if (rowsToInsert.length === 0) break + const lastRow = rowsToInsert[rowsToInsert.length - 1] + curcreatedAt = lastRow.createdAt + curId = lastRow.id + try { + await pg + .insertInto('SuggestedAction') + .values(rowsToInsert) + .onConflict((oc) => oc.doNothing()) + .execute() + } catch (e) { + await Promise.all( + rowsToInsert.map(async (row) => { + try { + await pg + .insertInto('SuggestedAction') + .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('SuggestedAction').indexDrop('createdAtId').run() + } catch { + // index already dropped + } + const pg = new Kysely({ + dialect: new PostgresDialect({ + pool: getPg() + }) + }) + await sql`TRUNCATE TABLE "SuggestedAction" CASCADE`.execute(pg) +} diff --git a/packages/server/postgres/select.ts b/packages/server/postgres/select.ts index f369d0b7e51..7e0e712333a 100644 --- a/packages/server/postgres/select.ts +++ b/packages/server/postgres/select.ts @@ -46,5 +46,15 @@ export const selectTemplateDimension = () => { } export const selectSuggestedAction = () => { - return getKysely().selectFrom('SuggestedAction').selectAll().where('removedAt', 'is', null) + return getKysely() + .selectFrom('SuggestedAction') + .selectAll() + .where('removedAt', 'is', null) + .$narrowType< + | {type: 'createNewTeam' | 'tryTheDemo'} + | { + type: 'inviteYourTeam' | 'tryRetroMeeting' | 'tryActionMeeting' + teamId: string + } + >() } diff --git a/packages/server/postgres/types/index.d.ts b/packages/server/postgres/types/index.d.ts index 0b14ea47ccf..44db14c7827 100644 --- a/packages/server/postgres/types/index.d.ts +++ b/packages/server/postgres/types/index.d.ts @@ -4,7 +4,7 @@ import { OrganizationUser as OrganizationUserPG, TeamMember as TeamMemberPG } from '../pg.d' -import {selectTemplateScale, selectTemplateScaleRef} from '../select' +import {selectSuggestedAction, selectTemplateScale, selectTemplateScaleRef} from '../select' type ExtractTypeFromQueryBuilderSelect any> = ReturnType extends SelectQueryBuilder ? X : never @@ -12,6 +12,8 @@ type ExtractTypeFromQueryBuilderSelect any> = export type Discussion = Selectable export type OrganizationUser = Selectable +export type SuggestedAction = ExtractTypeFromQueryBuilderSelect + export type TeamMember = Selectable export type TemplateScale = ExtractTypeFromQueryBuilderSelect diff --git a/packages/server/safeMutations/acceptTeamInvitation.ts b/packages/server/safeMutations/acceptTeamInvitation.ts index c48cba5df64..54f8ea58cd2 100644 --- a/packages/server/safeMutations/acceptTeamInvitation.ts +++ b/packages/server/safeMutations/acceptTeamInvitation.ts @@ -15,7 +15,6 @@ const handleFirstAcceptedInvitation = async ( team: TeamSource, dataLoader: DataLoaderInstance ): Promise => { - const r = await getRethink() const now = new Date() const {id: teamId, isOnboardTeam} = team if (!isOnboardTeam) return null @@ -54,7 +53,6 @@ const handleFirstAcceptedInvitation = async ( } ] 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 7d1d944d65c..a8481af1f95 100644 --- a/packages/server/safeMutations/removeSuggestedAction.ts +++ b/packages/server/safeMutations/removeSuggestedAction.ts @@ -1,22 +1,15 @@ import {sql} from 'kysely' -import getRethink from '../database/rethinkDriver' import getKysely from '../postgres/getKysely' import {SuggestedAction} from '../postgres/pg' const removeSuggestedAction = async (userId: string, type: SuggestedAction['type']) => { - const r = await getRethink() - await getKysely() + const removedAction = await getKysely() .updateTable('SuggestedAction') .set({removedAt: sql`CURRENT_TIMESTAMP`}) .where('userId', '=', userId) .where('type', '=', type) - .execute() - return r - .table('SuggestedAction') - .getAll(userId, {index: 'userId'}) - .filter({removedAt: null, type}) - .update({removedAt: new Date()}, {returnChanges: true})('changes')(0)('new_val')('id') - .default(null) - .run() + .returning('id') + .executeTakeFirst() + return removedAction?.id } export default removeSuggestedAction diff --git a/packages/server/safeMutations/safeArchiveTeam.ts b/packages/server/safeMutations/safeArchiveTeam.ts index 2e4f9878a0e..ea18bb807da 100644 --- a/packages/server/safeMutations/safeArchiveTeam.ts +++ b/packages/server/safeMutations/safeArchiveTeam.ts @@ -9,7 +9,7 @@ const safeArchiveTeam = async (teamId: string, dataLoader: DataLoaderWorker) => const now = new Date() const teamMembers = await dataLoader.get('teamMembersByTeamId').load(teamId) const userIds = teamMembers.map((tm) => tm.userId) - const [rethinkResult, pgResult] = await Promise.all([ + const [rethinkResult, removedSuggestedActions, team] = await Promise.all([ r({ invitations: r .table('TeamInvitation') @@ -17,18 +17,14 @@ const safeArchiveTeam = async (teamId: string, dataLoader: DataLoaderWorker) => .filter({acceptedAt: null}) .update((invitation: RDatum) => ({ expiresAt: r.min([invitation('expiresAt'), now]) - })) as unknown as null, - removedSuggestedActionIds: r - .table('SuggestedAction') - .getAll(teamId, {index: 'teamId'}) - .update( - { - removedAt: now - }, - {returnChanges: true} - )('changes')('new_val')('id') - .default([]) as unknown as string[] + })) as unknown as null }).run(), + pg + .updateTable('SuggestedAction') + .set({removedAt: now}) + .where('teamId', '=', teamId) + .returning('id') + .execute(), pg .updateTable('Team') .set({isArchived: true}) @@ -43,7 +39,12 @@ const safeArchiveTeam = async (teamId: string, dataLoader: DataLoaderWorker) => ]) dataLoader.clearAll(['teamMembers', 'users', 'teams']) const users = await Promise.all(userIds.map((userId) => dataLoader.get('users').load(userId))) - return {...rethinkResult, team: pgResult ?? null, users} + return { + invitations: rethinkResult.invitations, + removedSuggestedActionIds: removedSuggestedActions.map(({id}) => id), + team: team ?? null, + users + } } export default safeArchiveTeam