diff --git a/codegen.json b/codegen.json index 83eaf7bf530..b584d84cc68 100644 --- a/codegen.json +++ b/codegen.json @@ -46,6 +46,9 @@ "config": { "contextType": "../graphql#GQLContext", "mappers": { + "RemoveAgendaItemPayload": "./types/RemoveAgendaItemPayload#RemoveAgendaItemPayloadSource", + "AddAgendaItemPayload": "./types/AddAgendaItemPayload#AddAgendaItemPayloadSource", + "UpdateAgendaItemPayload": "./types/UpdateAgendaItemPayload#UpdateAgendaItemPayloadSource", "TeamMeetingSettings": "../../postgres/types/index#MeetingSettings as TeamMeetingSettingsDB", "TeamPromptMeetingSettings": "../../postgres/types/index#MeetingSettings as TeamMeetingSettingsDB", "PokerMeetingSettings": "../../postgres/types/index#PokerMeetingSettings as PokerMeetingSettingsDB", @@ -66,7 +69,7 @@ "AddTeamMemberIntegrationAuthSuccess": "./types/AddTeamMemberIntegrationAuthPayload#AddTeamMemberIntegrationAuthSuccessSource", "AddTranscriptionBotSuccess": "./types/AddTranscriptionBotSuccess#AddTranscriptionBotSuccessSource", "AddedNotification": "./types/AddedNotification#AddedNotificationSource", - "AgendaItem": "../../database/types/AgendaItem#default as AgendaItemDB", + "AgendaItem": "../../postgres/types/index#AgendaItem as AgendaItemDB", "ArchiveTeamPayload": "./types/ArchiveTeamPayload#ArchiveTeamPayloadSource", "AtlassianIntegration": "../../postgres/queries/getAtlassianAuthByUserIdTeamId#AtlassianAuth as AtlassianAuthDB", "AuthTokenPayload": "./types/AuthTokenPayload#AuthTokenPayloadSource", diff --git a/packages/client/modules/teamDashboard/components/AgendaInput/AgendaInput.tsx b/packages/client/modules/teamDashboard/components/AgendaInput/AgendaInput.tsx index 774fe017c27..25f37cfee19 100644 --- a/packages/client/modules/teamDashboard/components/AgendaInput/AgendaInput.tsx +++ b/packages/client/modules/teamDashboard/components/AgendaInput/AgendaInput.tsx @@ -12,11 +12,11 @@ import useHotkey from '../../../../hooks/useHotkey' import useMutationProps from '../../../../hooks/useMutationProps' import useTooltip from '../../../../hooks/useTooltip' import AddAgendaItemMutation from '../../../../mutations/AddAgendaItemMutation' +import {positionAfter} from '../../../../shared/sortOrder' import makeFieldColorPalette from '../../../../styles/helpers/makeFieldColorPalette' import makePlaceholderStyles from '../../../../styles/helpers/makePlaceholderStyles' import {PALETTE} from '../../../../styles/paletteV3' import ui from '../../../../styles/ui' -import getNextSortOrder from '../../../../utils/getNextSortOrder' import toTeamMemberId from '../../../../utils/relay/toTeamMemberId' const AgendaInputBlock = styled('div')({ @@ -123,7 +123,7 @@ const AgendaInput = (props: Props) => { const newAgendaItem = { content, pinned: false, - sortOrder: getNextSortOrder(agendaItems), + sortOrder: positionAfter(agendaItems.at(-1)?.sortOrder ?? ''), teamId, teamMemberId: toTeamMemberId(teamId, atmosphere.viewerId) } diff --git a/packages/client/modules/teamDashboard/components/AgendaList/AgendaList.tsx b/packages/client/modules/teamDashboard/components/AgendaList/AgendaList.tsx index 2ff4a1a27ea..b17b5291aea 100644 --- a/packages/client/modules/teamDashboard/components/AgendaList/AgendaList.tsx +++ b/packages/client/modules/teamDashboard/components/AgendaList/AgendaList.tsx @@ -9,9 +9,9 @@ import useAtmosphere from '../../../../hooks/useAtmosphere' import useEventCallback from '../../../../hooks/useEventCallback' import useGotoStageId from '../../../../hooks/useGotoStageId' import UpdateAgendaItemMutation from '../../../../mutations/UpdateAgendaItemMutation' +import {getSortOrder} from '../../../../shared/sortOrder' import {navItemRaised} from '../../../../styles/elevation' -import {AGENDA_ITEM, SORT_STEP} from '../../../../utils/constants' -import dndNoise from '../../../../utils/dndNoise' +import {AGENDA_ITEM} from '../../../../utils/constants' import AgendaItem from '../AgendaItem/AgendaItem' import AgendaListEmptyState from './AgendaListEmptyState' @@ -83,17 +83,7 @@ const AgendaList = (props: Props) => { return } - let sortOrder - if (destination.index === 0) { - sortOrder = destinationItem.sortOrder - SORT_STEP + dndNoise() - } else if (destination.index === agendaItems.length - 1) { - sortOrder = destinationItem.sortOrder + SORT_STEP + dndNoise() - } else { - const offset = source.index > destination.index ? -1 : 1 - sortOrder = - (agendaItems[destination.index + offset]!.sortOrder + destinationItem.sortOrder) / 2 + - dndNoise() - } + const sortOrder = getSortOrder(agendaItems, source.index, destination.index) UpdateAgendaItemMutation( atmosphere, {updatedAgendaItem: {id: sourceItem.id, sortOrder}}, diff --git a/packages/client/validation/makeAgendaItemSchema.ts b/packages/client/validation/makeAgendaItemSchema.ts deleted file mode 100644 index 3c3cc027008..00000000000 --- a/packages/client/validation/makeAgendaItemSchema.ts +++ /dev/null @@ -1,14 +0,0 @@ -import legitify from './legitify' -import Legitity from './Legitity' -import {compositeId, id} from './templates' - -export default function makeAgendaItemSchema() { - return legitify({ - content: (value: Legitity) => value.trim().max(63, 'Try something a little shorter'), - isActive: (value: Legitity) => value.boolean(), - pinned: (value: Legitity) => value.boolean(), - sortOrder: (value: Legitity) => value.float(), - teamId: id, - teamMemberId: compositeId - }) -} diff --git a/packages/client/validation/makeUpdateAgendaItemSchema.ts b/packages/client/validation/makeUpdateAgendaItemSchema.ts deleted file mode 100644 index 4967d9ed0bb..00000000000 --- a/packages/client/validation/makeUpdateAgendaItemSchema.ts +++ /dev/null @@ -1,15 +0,0 @@ -import legitify from './legitify' -import Legitity from './Legitity' -import {compositeId, id} from './templates' - -export default function makeUpdateAgendaItemSchema() { - return legitify({ - id: compositeId, - content: (value: Legitity) => value.trim().max(63, 'Try something a little shorter'), - isActive: (value: Legitity) => value.boolean(), - pinned: (value: Legitity) => value.boolean(), - sortOrder: (value: Legitity) => value.float(), - teamId: id, - teamMemberId: compositeId - }) -} diff --git a/packages/server/database/rethinkDriver.ts b/packages/server/database/rethinkDriver.ts index f36a3a73eef..18de76dcfcd 100644 --- a/packages/server/database/rethinkDriver.ts +++ b/packages/server/database/rethinkDriver.ts @@ -5,7 +5,6 @@ import TeamInvitation from '../database/types/TeamInvitation' import {AnyMeeting, AnyMeetingTeamMember} from '../postgres/types/Meeting' import getRethinkConfig from './getRethinkConfig' import {R} from './stricterR' -import AgendaItem from './types/AgendaItem' import Comment from './types/Comment' import MassInvitation from './types/MassInvitation' import NotificationKickedOut from './types/NotificationKickedOut' @@ -24,10 +23,6 @@ import RetrospectivePrompt from './types/RetrospectivePrompt' import Task from './types/Task' export type RethinkSchema = { - AgendaItem: { - type: AgendaItem - index: 'teamId' | 'meetingId' - } Comment: { type: Comment index: 'discussionId' diff --git a/packages/server/database/types/AgendaItem.ts b/packages/server/database/types/AgendaItem.ts deleted file mode 100644 index e88304fe36c..00000000000 --- a/packages/server/database/types/AgendaItem.ts +++ /dev/null @@ -1,61 +0,0 @@ -import generateUID from '../../generateUID' - -export interface AgendaItemInput { - id?: string - createdAt?: Date - isActive?: boolean - isComplete?: boolean - sortOrder?: number - teamId: string - teamMemberId: string - updatedAt?: Date - content: string - meetingId?: string - pinned?: boolean - pinnedParentId?: string -} - -export default class AgendaItem { - id: string - content: string - createdAt: Date - isActive: boolean - isComplete: boolean - sortOrder: number - teamId: string - teamMemberId: string - updatedAt: Date - meetingId?: string - pinned?: boolean - pinnedParentId?: string - - constructor(input: AgendaItemInput) { - const { - id, - createdAt, - isActive, - isComplete, - sortOrder, - teamId, - teamMemberId, - updatedAt, - content, - meetingId, - pinned, - pinnedParentId - } = input - const now = new Date() - this.id = id || generateUID() - this.createdAt = createdAt || now - this.isActive = isActive ?? true - this.isComplete = isComplete ?? false - this.sortOrder = sortOrder || 0 - this.teamId = teamId - this.teamMemberId = teamMemberId - this.updatedAt = updatedAt || now - this.content = content || '' - this.meetingId = meetingId - this.pinned = pinned - this.pinnedParentId = pinnedParentId - } -} diff --git a/packages/server/dataloader/foreignKeyLoaderMakers.ts b/packages/server/dataloader/foreignKeyLoaderMakers.ts index 51e21b4ca0b..d5855cf4e3c 100644 --- a/packages/server/dataloader/foreignKeyLoaderMakers.ts +++ b/packages/server/dataloader/foreignKeyLoaderMakers.ts @@ -172,8 +172,8 @@ export const teamPromptResponsesByMeetingId = foreignKeyLoaderMaker( getTeamPromptResponsesByMeetingIds ) -export const _pgagendaItemsByTeamId = foreignKeyLoaderMaker( - '_pgagendaItems', +export const agendaItemsByTeamId = foreignKeyLoaderMaker( + 'agendaItems', 'teamId', async (teamIds) => { return selectAgendaItems() @@ -184,8 +184,8 @@ export const _pgagendaItemsByTeamId = foreignKeyLoaderMaker( } ) -export const _pgagendaItemsByMeetingId = foreignKeyLoaderMaker( - '_pgagendaItems', +export const agendaItemsByMeetingId = foreignKeyLoaderMaker( + 'agendaItems', 'meetingId', async (meetingIds) => { return selectAgendaItems().where('meetingId', 'in', meetingIds).orderBy('sortOrder').execute() diff --git a/packages/server/dataloader/primaryKeyLoaderMakers.ts b/packages/server/dataloader/primaryKeyLoaderMakers.ts index 0849187482d..7ac2ed6389d 100644 --- a/packages/server/dataloader/primaryKeyLoaderMakers.ts +++ b/packages/server/dataloader/primaryKeyLoaderMakers.ts @@ -92,6 +92,6 @@ export const meetingSettings = primaryKeyLoaderMaker((ids: readonly string[]) => return selectMeetingSettings().where('id', 'in', ids).execute() }) -export const _pgagendaItems = primaryKeyLoaderMaker((ids: readonly string[]) => { +export const agendaItems = primaryKeyLoaderMaker((ids: readonly string[]) => { return selectAgendaItems().where('id', 'in', ids).execute() }) diff --git a/packages/server/dataloader/rethinkForeignKeyLoaderMakers.ts b/packages/server/dataloader/rethinkForeignKeyLoaderMakers.ts index 8cde8dc0bb6..24afe08e897 100644 --- a/packages/server/dataloader/rethinkForeignKeyLoaderMakers.ts +++ b/packages/server/dataloader/rethinkForeignKeyLoaderMakers.ts @@ -16,33 +16,6 @@ export const activeMeetingsByTeamId = new RethinkForeignKeyLoaderMaker( } ) -export const agendaItemsByTeamId = new RethinkForeignKeyLoaderMaker( - 'agendaItems', - 'teamId', - async (teamIds) => { - const r = await getRethink() - return r - .table('AgendaItem') - .getAll(r.args(teamIds), {index: 'teamId'}) - .filter({isActive: true}) - .orderBy('sortOrder') - .run() - } -) - -export const agendaItemsByMeetingId = new RethinkForeignKeyLoaderMaker( - 'agendaItems', - 'meetingId', - async (meetingIds) => { - const r = await getRethink() - return r - .table('AgendaItem') - .getAll(r.args(meetingIds), {index: 'meetingId'}) - .orderBy('sortOrder') - .run() - } -) - export const commentsByDiscussionId = new RethinkForeignKeyLoaderMaker( 'comments', 'discussionId', diff --git a/packages/server/dataloader/rethinkPrimaryKeyLoaderMakers.ts b/packages/server/dataloader/rethinkPrimaryKeyLoaderMakers.ts index b16d76e3adf..e2840c3ed0a 100644 --- a/packages/server/dataloader/rethinkPrimaryKeyLoaderMakers.ts +++ b/packages/server/dataloader/rethinkPrimaryKeyLoaderMakers.ts @@ -3,7 +3,6 @@ import RethinkPrimaryKeyLoaderMaker from './RethinkPrimaryKeyLoaderMaker' /** * all rethink dataloader types which also must exist in {@link rethinkDriver/RethinkSchema} */ -export const agendaItems = new RethinkPrimaryKeyLoaderMaker('AgendaItem') export const comments = new RethinkPrimaryKeyLoaderMaker('Comment') export const reflectPrompts = new RethinkPrimaryKeyLoaderMaker('ReflectPrompt') export const massInvitations = new RethinkPrimaryKeyLoaderMaker('MassInvitation') diff --git a/packages/server/graphql/mutations/addAgendaItem.ts b/packages/server/graphql/mutations/addAgendaItem.ts deleted file mode 100644 index c51a15c2f59..00000000000 --- a/packages/server/graphql/mutations/addAgendaItem.ts +++ /dev/null @@ -1,84 +0,0 @@ -import {GraphQLNonNull} from 'graphql' -import {SubscriptionChannel} from 'parabol-client/types/constEnums' -import makeAgendaItemSchema from 'parabol-client/validation/makeAgendaItemSchema' -import {positionAfter} from '../../../client/shared/sortOrder' -import getRethink from '../../database/rethinkDriver' -import AgendaItem, {AgendaItemInput} from '../../database/types/AgendaItem' -import generateUID from '../../generateUID' -import getKysely from '../../postgres/getKysely' -import {analytics} from '../../utils/analytics/analytics' -import {getUserId, isTeamMember} from '../../utils/authorization' -import publish from '../../utils/publish' -import standardError from '../../utils/standardError' -import AddAgendaItemPayload from '../types/AddAgendaItemPayload' -import CreateAgendaItemInput, {CreateAgendaItemInputType} from '../types/CreateAgendaItemInput' -import {GQLContext} from './../graphql' -import addAgendaItemToActiveActionMeeting from './helpers/addAgendaItemToActiveActionMeeting' - -export default { - type: AddAgendaItemPayload, - description: 'Create a new agenda item', - args: { - newAgendaItem: { - type: new GraphQLNonNull(CreateAgendaItemInput), - description: 'The new task including an id, teamMemberId, and content' - } - }, - async resolve( - _source: unknown, - {newAgendaItem}: {newAgendaItem: CreateAgendaItemInputType}, - {authToken, dataLoader, socketId: mutatorId}: GQLContext - ) { - const r = await getRethink() - const operationId = dataLoader.share() - const subOptions = {mutatorId, operationId} - const viewerId = getUserId(authToken) - // AUTH - const {teamId} = newAgendaItem - if (!isTeamMember(authToken, teamId)) { - return standardError(new Error('Team not found'), {userId: viewerId}) - } - const viewer = await dataLoader.get('users').loadNonNull(viewerId) - // VALIDATION - const schema = makeAgendaItemSchema() - const {errors, data: validNewAgendaItem} = schema(newAgendaItem) - if (Object.keys(errors).length) { - return standardError(new Error('Failed input validation'), {userId: viewerId}) - } - - // RESOLUTION - const teamAgendaItems = await dataLoader.get('agendaItemsByTeamId').load(teamId) - const lastAgendaItem = teamAgendaItems.at(-1) - const lastSortOrder = lastAgendaItem?.sortOrder ? String(lastAgendaItem.sortOrder) : '' - // this is just during the migration of AgendaItem table - const sortOrder = positionAfter(lastSortOrder) - const agendaItemId = `${teamId}::${generateUID()}` - await r - .table('AgendaItem') - .insert( - new AgendaItem({ - ...validNewAgendaItem, - id: agendaItemId, - teamId - } as AgendaItemInput) - ) - .run() - await getKysely() - .insertInto('AgendaItem') - .values({ - id: agendaItemId, - content: newAgendaItem.content, - meetingId: newAgendaItem.meetingId, - pinned: newAgendaItem.pinned, - sortOrder, - teamId, - teamMemberId: newAgendaItem.teamMemberId - }) - .execute() - const meetingId = await addAgendaItemToActiveActionMeeting(agendaItemId, teamId, dataLoader) - analytics.addedAgendaItem(viewer, teamId, meetingId) - const data = {agendaItemId, meetingId} - publish(SubscriptionChannel.TEAM, teamId, 'AddAgendaItemPayload', data, subOptions) - return data - } -} diff --git a/packages/server/graphql/mutations/endCheckIn.ts b/packages/server/graphql/mutations/endCheckIn.ts index c5bfbca7a28..a3e0c2573da 100644 --- a/packages/server/graphql/mutations/endCheckIn.ts +++ b/packages/server/graphql/mutations/endCheckIn.ts @@ -7,13 +7,13 @@ import {positionAfter} from '../../../client/shared/sortOrder' import {checkTeamsLimit} from '../../billing/helpers/teamLimitsCheck' import getRethink from '../../database/rethinkDriver' import {RDatum} from '../../database/stricterR' -import AgendaItem from '../../database/types/AgendaItem' import MeetingAction from '../../database/types/MeetingAction' import Task from '../../database/types/Task' import TimelineEventCheckinComplete from '../../database/types/TimelineEventCheckinComplete' import {DataLoaderInstance} from '../../dataloader/RootDataLoader' import generateUID from '../../generateUID' import getKysely from '../../postgres/getKysely' +import {AgendaItem} from '../../postgres/types' import archiveTasksForDB from '../../safeMutations/archiveTasksForDB' import removeSuggestedAction from '../../safeMutations/removeSuggestedAction' import {Logger} from '../../utils/Logger' @@ -69,15 +69,7 @@ const clearAgendaItems = async (teamId: string, dataLoader: DataLoaderInstance) .set({isActive: false}) .where('teamId', '=', teamId) .execute() - const r = await getRethink() dataLoader.clearAll('agendaItems') - return r - .table('AgendaItem') - .getAll(teamId, {index: 'teamId'}) - .update({ - isActive: false - }) - .run() } const getPinnedAgendaItems = async (teamId: string, dataLoader: DataLoaderInstance) => { @@ -89,30 +81,22 @@ const clonePinnedAgendaItems = async ( pinnedAgendaItems: AgendaItem[], dataLoader: DataLoaderInstance ) => { - const r = await getRethink() + let curSortOrder = '' const clonedPins = pinnedAgendaItems.map((agendaItem) => { const agendaItemId = `${agendaItem.teamId}::${generateUID()}` - return new AgendaItem({ + const sortOrder = positionAfter(curSortOrder) + curSortOrder = sortOrder + return { id: agendaItemId, content: agendaItem.content, pinned: agendaItem.pinned, pinnedParentId: agendaItem.pinnedParentId ? agendaItem.pinnedParentId : agendaItemId, - sortOrder: agendaItem.sortOrder, + sortOrder, teamId: agendaItem.teamId, teamMemberId: agendaItem.teamMemberId - }) - }) - await r.table('AgendaItem').insert(clonedPins).run() - let curSortOrder = '' - const pgClonedPins = clonedPins.map((agendaItems) => { - const sortOrder = positionAfter(curSortOrder) - curSortOrder = sortOrder - return { - ...agendaItems, - sortOrder } }) - await getKysely().insertInto('AgendaItem').values(pgClonedPins).execute() + await getKysely().insertInto('AgendaItem').values(clonedPins).execute() dataLoader.clearAll('agendaItems') } diff --git a/packages/server/graphql/mutations/helpers/addAgendaItemToActiveActionMeeting.ts b/packages/server/graphql/mutations/helpers/addAgendaItemToActiveActionMeeting.ts index db01b387b0d..4ceb9ab23c9 100644 --- a/packages/server/graphql/mutations/helpers/addAgendaItemToActiveActionMeeting.ts +++ b/packages/server/graphql/mutations/helpers/addAgendaItemToActiveActionMeeting.ts @@ -47,7 +47,6 @@ const addAgendaItemToActiveActionMeeting = async ( updatedAt: now }) .run(), - r.table('AgendaItem').get(agendaItemId).update({meetingId: meetingId}).run(), getKysely().updateTable('AgendaItem').set({meetingId}).where('id', '=', agendaItemId).execute(), insertDiscussions([ { diff --git a/packages/server/graphql/mutations/removeAgendaItem.ts b/packages/server/graphql/mutations/removeAgendaItem.ts deleted file mode 100644 index 941c0f9dc30..00000000000 --- a/packages/server/graphql/mutations/removeAgendaItem.ts +++ /dev/null @@ -1,61 +0,0 @@ -import {GraphQLID, GraphQLNonNull} from 'graphql' -import {SubscriptionChannel} from 'parabol-client/types/constEnums' -import getRethink from '../../database/rethinkDriver' -import AgendaItemsStage from '../../database/types/AgendaItemsStage' -import getKysely from '../../postgres/getKysely' -import {getUserId, isTeamMember} from '../../utils/authorization' -import publish from '../../utils/publish' -import standardError from '../../utils/standardError' -import {GQLContext} from '../graphql' -import RemoveAgendaItemPayload from '../types/RemoveAgendaItemPayload' -import removeStagesFromMeetings from './helpers/removeStagesFromMeetings' - -export default { - type: RemoveAgendaItemPayload, - description: 'Remove an agenda item', - args: { - agendaItemId: { - type: new GraphQLNonNull(GraphQLID), - description: 'The agenda item unique id' - } - }, - async resolve( - _source: unknown, - {agendaItemId}: {agendaItemId: string}, - {authToken, dataLoader, socketId: mutatorId}: GQLContext - ) { - const r = await getRethink() - const operationId = dataLoader.share() - const subOptions = {mutatorId, operationId} - const viewerId = getUserId(authToken) - - // AUTH - // id is of format 'teamId::randomId' - const [teamId] = agendaItemId.split('::') as [string] - if (!isTeamMember(authToken, teamId)) { - return standardError(new Error('Team not found'), {userId: viewerId}) - } - - // RESOLUTION - const agendaItem = await r - .table('AgendaItem') - .get(agendaItemId) - .update({isActive: false}, {returnChanges: true})('changes')(0)('old_val') - .default(null) - .run() - await getKysely() - .updateTable('AgendaItem') - .set({isActive: false}) - .where('id', '=', agendaItemId) - .returning('id') - .execute() - if (!agendaItem) { - return standardError(new Error('Agenda item not found'), {userId: viewerId}) - } - const filterFn = (stage: AgendaItemsStage) => stage.agendaItemId === agendaItemId - await removeStagesFromMeetings(filterFn, teamId, dataLoader) - const data = {agendaItem, meetingId: agendaItem.meetingId} - publish(SubscriptionChannel.TEAM, teamId, 'RemoveAgendaItemPayload', data, subOptions) - return data - } -} diff --git a/packages/server/graphql/mutations/updateAgendaItem.ts b/packages/server/graphql/mutations/updateAgendaItem.ts deleted file mode 100644 index 46d08998dbc..00000000000 --- a/packages/server/graphql/mutations/updateAgendaItem.ts +++ /dev/null @@ -1,111 +0,0 @@ -import {GraphQLNonNull} from 'graphql' -import {SubscriptionChannel} from 'parabol-client/types/constEnums' -import makeUpdateAgendaItemSchema from 'parabol-client/validation/makeUpdateAgendaItemSchema' -import {getSortOrder} from '../../../client/shared/sortOrder' -import getRethink from '../../database/rethinkDriver' -import AgendaItemsStage from '../../database/types/AgendaItemsStage' -import getKysely from '../../postgres/getKysely' -import {getUserId, isTeamMember} from '../../utils/authorization' -import getPhase from '../../utils/getPhase' -import publish from '../../utils/publish' -import standardError from '../../utils/standardError' -import {GQLContext} from '../graphql' -import UpdateAgendaItemInput, {UpdateAgendaItemInputType} from '../types/UpdateAgendaItemInput' -import UpdateAgendaItemPayload from '../types/UpdateAgendaItemPayload' - -export default { - type: UpdateAgendaItemPayload, - description: 'Update an agenda item', - args: { - updatedAgendaItem: { - type: new GraphQLNonNull(UpdateAgendaItemInput), - description: 'The updated item including an id, content, status, sortOrder' - } - }, - async resolve( - _source: unknown, - {updatedAgendaItem}: {updatedAgendaItem: UpdateAgendaItemInputType}, - {authToken, dataLoader, socketId: mutatorId}: GQLContext - ) { - const now = new Date() - const r = await getRethink() - const pg = getKysely() - const operationId = dataLoader.share() - const subOptions = {mutatorId, operationId} - const viewerId = getUserId(authToken) - - // AUTH - const {id: agendaItemId} = updatedAgendaItem - const [teamId] = agendaItemId.split('::') as [string] - if (!isTeamMember(authToken, teamId)) { - return standardError(new Error('Team not found'), {userId: viewerId}) - } - - // VALIDATION - const schema = makeUpdateAgendaItemSchema() - const { - errors, - data: {id, ...doc} - } = schema(updatedAgendaItem) as any - if (Object.keys(errors).length) { - return standardError(new Error('Failed input validation'), {userId: viewerId}) - } - - // RESOLUTION - const oldAgendaItems = await dataLoader.get('agendaItemsByTeamId').load(teamId) - const fromIdx = oldAgendaItems.findIndex((agendaItem) => agendaItem.id === id) - await r - .table('AgendaItem') - .get(id) - .update({ - ...doc, - updatedAt: now - }) - .run() - dataLoader.clearAll('agendaItems') - if (doc.sortOrder !== null && doc.sortOrder !== undefined) { - const nextAgendaItems = await dataLoader.get('agendaItemsByTeamId').load(teamId) - const pgagendaItems = await dataLoader.get('_pgagendaItemsByTeamId').load(teamId) - const toIdx = nextAgendaItems.findIndex((agendaItem) => agendaItem.id === id) - const pgSortOrder = getSortOrder(pgagendaItems, fromIdx, toIdx) - await pg - .updateTable('AgendaItem') - .set({sortOrder: pgSortOrder}) - .where('id', '=', id) - .execute() - } else { - await pg - .updateTable('AgendaItem') - .set({pinned: doc.pinned, content: doc.content}) - .where('id', '=', id) - .execute() - } - - const activeMeetings = await dataLoader.get('activeMeetingsByTeamId').load(teamId) - const actionMeeting = activeMeetings.find( - (activeMeeting) => activeMeeting.meetingType === 'action' - ) - const meetingId = actionMeeting?.id ?? null - if (actionMeeting) { - const {id: meetingId, phases} = actionMeeting - const agendaItemPhase = getPhase(phases, 'agendaitems') - const {stages} = agendaItemPhase - const agendaItems = await dataLoader.get('agendaItemsByTeamId').load(teamId) - const getSortOrder = (stage: AgendaItemsStage) => { - const agendaItem = agendaItems.find((item) => item.id === stage.agendaItemId) - return (agendaItem && agendaItem.sortOrder) || 0 - } - stages.sort((a, b) => (getSortOrder(a) > getSortOrder(b) ? 1 : -1)) - await r - .table('NewMeeting') - .get(meetingId) - .update({ - phases - }) - .run() - } - const data = {agendaItemId, meetingId} - publish(SubscriptionChannel.TEAM, teamId, 'UpdateAgendaItemPayload', data, subOptions) - return data - } -} diff --git a/packages/server/graphql/private/mutations/hardDeleteUser.ts b/packages/server/graphql/private/mutations/hardDeleteUser.ts index 7bf8932ca67..cb7a05dc94b 100644 --- a/packages/server/graphql/private/mutations/hardDeleteUser.ts +++ b/packages/server/graphql/private/mutations/hardDeleteUser.ts @@ -67,7 +67,6 @@ const hardDeleteUser: MutationResolvers['hardDeleteUser'] = async ( dataLoader.get('meetingMembersByUserId').load(userIdToDelete) ]) const teamIds = teamMembers.map(({teamId}) => teamId) - const teamMemberIds = teamMembers.map(({id}) => id) const meetingIds = meetingMembers.map(({meetingId}) => meetingId) const discussions = await pg.query(`SELECT "id" FROM "Discussion" WHERE "teamId" = ANY ($1);`, [ @@ -94,11 +93,6 @@ const hardDeleteUser: MutationResolvers['hardDeleteUser'] = async ( .getAll(r.args(teamIds), {index: 'teamId'}) .filter((row: RValue) => row('createdBy').eq(userIdToDelete)) .delete(), - agendaItem: r - .table('AgendaItem') - .getAll(r.args(teamIds), {index: 'teamId'}) - .filter((row: RValue) => r(teamMemberIds).contains(row('teamMemberId'))) - .delete(), pushInvitation: r.table('PushInvitation').getAll(userIdToDelete, {index: 'userId'}).delete(), slackNotification: r .table('SlackNotification') diff --git a/packages/server/graphql/public/mutations/addAgendaItem.ts b/packages/server/graphql/public/mutations/addAgendaItem.ts new file mode 100644 index 00000000000..823be535cc0 --- /dev/null +++ b/packages/server/graphql/public/mutations/addAgendaItem.ts @@ -0,0 +1,54 @@ +import {SubscriptionChannel} from 'parabol-client/types/constEnums' +import {positionAfter} from '../../../../client/shared/sortOrder' +import generateUID from '../../../generateUID' +import getKysely from '../../../postgres/getKysely' +import {analytics} from '../../../utils/analytics/analytics' +import {getUserId, isTeamMember} from '../../../utils/authorization' +import publish from '../../../utils/publish' +import standardError from '../../../utils/standardError' +import addAgendaItemToActiveActionMeeting from '../../mutations/helpers/addAgendaItemToActiveActionMeeting' +import {MutationResolvers} from '../resolverTypes' + +const addAgendaItem: MutationResolvers['addAgendaItem'] = async ( + _source, + {newAgendaItem}, + {authToken, dataLoader, socketId: mutatorId} +) => { + const operationId = dataLoader.share() + const subOptions = {mutatorId, operationId} + const viewerId = getUserId(authToken) + // AUTH + const {teamId} = newAgendaItem + if (!isTeamMember(authToken, teamId)) { + return standardError(new Error('Team not found'), {userId: viewerId}) + } + const viewer = await dataLoader.get('users').loadNonNull(viewerId) + // VALIDATION + if (newAgendaItem.content.length > 64) { + return {error: {message: 'Agenda item must be shorter'}} + } + + // RESOLUTION + const teamAgendaItems = await dataLoader.get('agendaItemsByTeamId').load(teamId) + const lastAgendaItem = teamAgendaItems.at(-1) + const agendaItemId = `${teamId}::${generateUID()}` + await getKysely() + .insertInto('AgendaItem') + .values({ + id: agendaItemId, + content: newAgendaItem.content, + meetingId: newAgendaItem.meetingId, + pinned: newAgendaItem.pinned, + sortOrder: positionAfter(lastAgendaItem?.sortOrder ?? ''), + teamId, + teamMemberId: newAgendaItem.teamMemberId + }) + .execute() + const meetingId = await addAgendaItemToActiveActionMeeting(agendaItemId, teamId, dataLoader) + analytics.addedAgendaItem(viewer, teamId, meetingId) + const data = {agendaItemId, meetingId} + publish(SubscriptionChannel.TEAM, teamId, 'AddAgendaItemPayload', data, subOptions) + return data +} + +export default addAgendaItem diff --git a/packages/server/graphql/public/mutations/removeAgendaItem.ts b/packages/server/graphql/public/mutations/removeAgendaItem.ts new file mode 100644 index 00000000000..58b964ae9f8 --- /dev/null +++ b/packages/server/graphql/public/mutations/removeAgendaItem.ts @@ -0,0 +1,43 @@ +import {SubscriptionChannel} from 'parabol-client/types/constEnums' +import AgendaItemsStage from '../../../database/types/AgendaItemsStage' +import getKysely from '../../../postgres/getKysely' +import {getUserId, isTeamMember} from '../../../utils/authorization' +import publish from '../../../utils/publish' +import standardError from '../../../utils/standardError' +import removeStagesFromMeetings from '../../mutations/helpers/removeStagesFromMeetings' +import {MutationResolvers} from '../resolverTypes' + +const removeAgendaItem: MutationResolvers['removeAgendaItem'] = async ( + _source, + {agendaItemId}, + {authToken, dataLoader, socketId: mutatorId} +) => { + const operationId = dataLoader.share() + const subOptions = {mutatorId, operationId} + const viewerId = getUserId(authToken) + + // AUTH + // id is of format 'teamId::randomId' + const [teamId] = agendaItemId.split('::') as [string] + if (!isTeamMember(authToken, teamId)) { + return standardError(new Error('Team not found'), {userId: viewerId}) + } + + // RESOLUTION + const agendaItem = await getKysely() + .updateTable('AgendaItem') + .set({isActive: false}) + .where('id', '=', agendaItemId) + .returning(['id', 'meetingId']) + .executeTakeFirst() + if (!agendaItem) { + return standardError(new Error('Agenda item not found'), {userId: viewerId}) + } + const filterFn = (stage: AgendaItemsStage) => stage.agendaItemId === agendaItemId + await removeStagesFromMeetings(filterFn, teamId, dataLoader) + const data = {agendaItemId, meetingId: agendaItem.meetingId} + publish(SubscriptionChannel.TEAM, teamId, 'RemoveAgendaItemPayload', data, subOptions) + return data +} + +export default removeAgendaItem diff --git a/packages/server/graphql/public/mutations/startCheckIn.ts b/packages/server/graphql/public/mutations/startCheckIn.ts index 6fe86b06f0a..b90ffd146a7 100644 --- a/packages/server/graphql/public/mutations/startCheckIn.ts +++ b/packages/server/graphql/public/mutations/startCheckIn.ts @@ -90,7 +90,6 @@ const startCheckIn: MutationResolvers['startCheckIn'] = async ( .insert(new ActionMeetingMember({meetingId, userId: viewerId, teamId})) .run(), updateTeamByTeamId(updates, teamId), - r.table('AgendaItem').getAll(r.args(agendaItemIds)).update({meetingId}).run(), agendaItemIds.length && getKysely() .updateTable('AgendaItem') diff --git a/packages/server/graphql/public/mutations/updateAgendaItem.ts b/packages/server/graphql/public/mutations/updateAgendaItem.ts new file mode 100644 index 00000000000..51c506e8fdb --- /dev/null +++ b/packages/server/graphql/public/mutations/updateAgendaItem.ts @@ -0,0 +1,73 @@ +import {SubscriptionChannel} from 'parabol-client/types/constEnums' +import getRethink from '../../../database/rethinkDriver' +import AgendaItemsStage from '../../../database/types/AgendaItemsStage' +import getKysely from '../../../postgres/getKysely' +import {getUserId, isTeamMember} from '../../../utils/authorization' +import getPhase from '../../../utils/getPhase' +import publish from '../../../utils/publish' +import standardError from '../../../utils/standardError' +import {MutationResolvers} from '../resolverTypes' + +const updateAgendaItem: MutationResolvers['updateAgendaItem'] = async ( + _source, + {updatedAgendaItem}, + {authToken, dataLoader, socketId: mutatorId} +) => { + const r = await getRethink() + const pg = getKysely() + const operationId = dataLoader.share() + const subOptions = {mutatorId, operationId} + const viewerId = getUserId(authToken) + + // AUTH + const {id: agendaItemId, content, pinned, sortOrder} = updatedAgendaItem + const [teamId] = agendaItemId.split('::') as [string] + if (!isTeamMember(authToken, teamId)) { + return standardError(new Error('Team not found'), {userId: viewerId}) + } + + // VALIDATION + if (content && content.length > 64) { + return {error: {message: 'Agenda item must be shorter'}} + } + + // RESOLUTION + await pg + .updateTable('AgendaItem') + .set({ + pinned: pinned ?? undefined, + content: content ?? undefined, + sortOrder: sortOrder ?? undefined + }) + .where('id', '=', agendaItemId) + .execute() + + const activeMeetings = await dataLoader.get('activeMeetingsByTeamId').load(teamId) + const actionMeeting = activeMeetings.find( + (activeMeeting) => activeMeeting.meetingType === 'action' + ) + const meetingId = actionMeeting?.id ?? null + if (actionMeeting) { + const {id: meetingId, phases} = actionMeeting + const agendaItemPhase = getPhase(phases, 'agendaitems') + const {stages} = agendaItemPhase + const agendaItems = await dataLoader.get('agendaItemsByTeamId').load(teamId) + const getSortOrder = (stage: AgendaItemsStage) => { + const agendaItem = agendaItems.find((item) => item.id === stage.agendaItemId) + return (agendaItem && agendaItem.sortOrder) || 0 + } + stages.sort((a, b) => (getSortOrder(a) > getSortOrder(b) ? 1 : -1)) + await r + .table('NewMeeting') + .get(meetingId) + .update({ + phases + }) + .run() + } + const data = {agendaItemId, meetingId} + publish(SubscriptionChannel.TEAM, teamId, 'UpdateAgendaItemPayload', data, subOptions) + return data +} + +export default updateAgendaItem diff --git a/packages/server/graphql/public/typeDefs/AgendaItem.graphql b/packages/server/graphql/public/typeDefs/AgendaItem.graphql index a2871a78b03..c985ed2bf38 100644 --- a/packages/server/graphql/public/typeDefs/AgendaItem.graphql +++ b/packages/server/graphql/public/typeDefs/AgendaItem.graphql @@ -40,7 +40,7 @@ type AgendaItem { """ The sort order of the agenda item in the list """ - sortOrder: Float! + sortOrder: String! """ *The team for this agenda item diff --git a/packages/server/graphql/public/typeDefs/CreateAgendaItemInput.graphql b/packages/server/graphql/public/typeDefs/CreateAgendaItemInput.graphql index d04e556d7de..17981db5e0a 100644 --- a/packages/server/graphql/public/typeDefs/CreateAgendaItemInput.graphql +++ b/packages/server/graphql/public/typeDefs/CreateAgendaItemInput.graphql @@ -18,7 +18,7 @@ input CreateAgendaItemInput { """ The sort order of the agenda item in the list """ - sortOrder: Float + sortOrder: String """ The meeting ID of the agenda item diff --git a/packages/server/graphql/public/typeDefs/UpdateAgendaItemInput.graphql b/packages/server/graphql/public/typeDefs/UpdateAgendaItemInput.graphql index d0110ae03ca..ce4bea7817b 100644 --- a/packages/server/graphql/public/typeDefs/UpdateAgendaItemInput.graphql +++ b/packages/server/graphql/public/typeDefs/UpdateAgendaItemInput.graphql @@ -22,5 +22,5 @@ input UpdateAgendaItemInput { """ The sort order of the agenda item in the list """ - sortOrder: Float + sortOrder: String } diff --git a/packages/server/graphql/public/types/ActionMeeting.ts b/packages/server/graphql/public/types/ActionMeeting.ts index daed42c627f..f6a7e04939f 100644 --- a/packages/server/graphql/public/types/ActionMeeting.ts +++ b/packages/server/graphql/public/types/ActionMeeting.ts @@ -5,7 +5,7 @@ import {ActionMeetingResolvers} from '../resolverTypes' const ActionMeeting: ActionMeetingResolvers = { agendaItem: async ({id: meetingId}, {agendaItemId}, {dataLoader}) => { - const agendaItem = await dataLoader.get('agendaItems').load(agendaItemId) + const agendaItem = await dataLoader.get('agendaItems').loadNonNull(agendaItemId) if (agendaItem.meetingId !== meetingId) return null return agendaItem }, diff --git a/packages/server/graphql/public/types/AddAgendaItemPayload.ts b/packages/server/graphql/public/types/AddAgendaItemPayload.ts new file mode 100644 index 00000000000..f82f7966890 --- /dev/null +++ b/packages/server/graphql/public/types/AddAgendaItemPayload.ts @@ -0,0 +1,24 @@ +import {AddAgendaItemPayloadResolvers} from '../resolverTypes' + +export type AddAgendaItemPayloadSource = + | { + agendaItemId: string + meetingId?: string + } + | {error: {message: string}} + +const AddAgendaItemPayload: AddAgendaItemPayloadResolvers = { + agendaItem: (source, _args, {dataLoader}) => { + return 'agendaItemId' in source + ? dataLoader.get('agendaItems').loadNonNull(source.agendaItemId) + : null + }, + + meeting: (source, _args, {dataLoader}) => { + return 'meetingId' in source && source.meetingId + ? dataLoader.get('newMeetings').load(source.meetingId) + : null + } +} + +export default AddAgendaItemPayload diff --git a/packages/server/graphql/public/types/AgendaItemsStage.ts b/packages/server/graphql/public/types/AgendaItemsStage.ts index d260932a88f..1dcfed67720 100644 --- a/packages/server/graphql/public/types/AgendaItemsStage.ts +++ b/packages/server/graphql/public/types/AgendaItemsStage.ts @@ -3,7 +3,7 @@ import {AgendaItemsStageResolvers} from '../resolverTypes' const AgendaItemsStage: AgendaItemsStageResolvers = { __isTypeOf: ({phaseType}) => phaseType === 'agendaitems', agendaItem: ({agendaItemId}, _args, {dataLoader}) => { - return dataLoader.get('agendaItems').load(agendaItemId) + return dataLoader.get('agendaItems').loadNonNull(agendaItemId) } } diff --git a/packages/server/graphql/public/types/RemoveAgendaItemPayload.ts b/packages/server/graphql/public/types/RemoveAgendaItemPayload.ts new file mode 100644 index 00000000000..3527f938e25 --- /dev/null +++ b/packages/server/graphql/public/types/RemoveAgendaItemPayload.ts @@ -0,0 +1,24 @@ +import {RemoveAgendaItemPayloadResolvers} from '../resolverTypes' + +export type RemoveAgendaItemPayloadSource = + | { + agendaItemId: string + meetingId?: string | null + } + | {error: {message: string}} + +const RemoveAgendaItemPayload: RemoveAgendaItemPayloadResolvers = { + agendaItem: (source, _args, {dataLoader}) => { + return 'agendaItemId' in source + ? dataLoader.get('agendaItems').loadNonNull(source.agendaItemId) + : null + }, + + meeting: (source, _args, {dataLoader}) => { + return 'meetingId' in source && source.meetingId + ? dataLoader.get('newMeetings').load(source.meetingId) + : null + } +} + +export default RemoveAgendaItemPayload diff --git a/packages/server/graphql/public/types/UpdateAgendaItemPayload.ts b/packages/server/graphql/public/types/UpdateAgendaItemPayload.ts new file mode 100644 index 00000000000..f267c8be980 --- /dev/null +++ b/packages/server/graphql/public/types/UpdateAgendaItemPayload.ts @@ -0,0 +1,24 @@ +import {UpdateAgendaItemPayloadResolvers} from '../resolverTypes' + +export type UpdateAgendaItemPayloadSource = + | { + agendaItemId: string + meetingId?: string | null + } + | {error: {message: string}} + +const UpdateAgendaItemPayload: UpdateAgendaItemPayloadResolvers = { + agendaItem: (source, _args, {dataLoader}) => { + return 'agendaItemId' in source + ? dataLoader.get('agendaItems').loadNonNull(source.agendaItemId) + : null + }, + + meeting: (source, _args, {dataLoader}) => { + return 'meetingId' in source && source.meetingId + ? dataLoader.get('newMeetings').load(source.meetingId) + : null + } +} + +export default UpdateAgendaItemPayload diff --git a/packages/server/graphql/rootMutation.ts b/packages/server/graphql/rootMutation.ts index 91654c128b6..9493f6e092c 100644 --- a/packages/server/graphql/rootMutation.ts +++ b/packages/server/graphql/rootMutation.ts @@ -1,6 +1,5 @@ import {GraphQLObjectType} from 'graphql' import {GQLContext} from './graphql' -import addAgendaItem from './mutations/addAgendaItem' import addAtlassianAuth from './mutations/addAtlassianAuth' import addComment from './mutations/addComment' import addGitHubAuth from './mutations/addGitHubAuth' @@ -63,7 +62,6 @@ import promoteToTeamLead from './mutations/promoteToTeamLead' import pushInvitation from './mutations/pushInvitation' import reflectTemplatePromptUpdateDescription from './mutations/reflectTemplatePromptUpdateDescription' import reflectTemplatePromptUpdateGroupColor from './mutations/reflectTemplatePromptUpdateGroupColor' -import removeAgendaItem from './mutations/removeAgendaItem' import removeAtlassianAuth from './mutations/removeAtlassianAuth' import removeGitHubAuth from './mutations/removeGitHubAuth' import removeIntegrationProvider from './mutations/removeIntegrationProvider' @@ -96,7 +94,6 @@ import setTaskHighlight from './mutations/setTaskHighlight' import startDraggingReflection from './mutations/startDraggingReflection' import startSprintPoker from './mutations/startSprintPoker' import toggleTeamDrawer from './mutations/toggleTeamDrawer' -import updateAgendaItem from './mutations/updateAgendaItem' import updateAzureDevOpsDimensionField from './mutations/updateAzureDevOpsDimensionField' import updateCommentContent from './mutations/updateCommentContent' import updateDragLocation from './mutations/updateDragLocation' @@ -119,7 +116,6 @@ export default new GraphQLObjectType({ name: 'Mutation', fields: () => ({ - addAgendaItem, addAtlassianAuth, addComment, addPokerTemplateDimension, @@ -173,7 +169,6 @@ export default new GraphQLObjectType({ reflectTemplatePromptUpdateDescription, pokerTemplateDimensionUpdateDescription, reflectTemplatePromptUpdateGroupColor, - removeAgendaItem, removeAtlassianAuth, removeGitHubAuth, removeOrgUser, @@ -201,7 +196,6 @@ export default new GraphQLObjectType({ startDraggingReflection, startSprintPoker, setTaskHighlight, - updateAgendaItem, updateCommentContent, oldUpdateCreditCard, updatePokerTemplateDimensionScale, diff --git a/packages/server/graphql/types/AddAgendaItemPayload.ts b/packages/server/graphql/types/AddAgendaItemPayload.ts deleted file mode 100644 index b2960e97088..00000000000 --- a/packages/server/graphql/types/AddAgendaItemPayload.ts +++ /dev/null @@ -1,32 +0,0 @@ -import {GraphQLID, GraphQLObjectType} from 'graphql' -import {GQLContext} from '../graphql' -import AgendaItem from './AgendaItem' -import NewMeeting from './NewMeeting' -import StandardMutationError from './StandardMutationError' - -const AddAgendaItemPayload = new GraphQLObjectType({ - name: 'AddAgendaItemPayload', - fields: () => ({ - error: { - type: StandardMutationError - }, - agendaItem: { - type: AgendaItem, - resolve: ({agendaItemId}, _args: unknown, {dataLoader}) => { - return dataLoader.get('agendaItems').load(agendaItemId) - } - }, - meetingId: { - type: GraphQLID - }, - meeting: { - type: NewMeeting, - description: 'The meeting with the updated agenda item, if any', - resolve: ({meetingId}, _args: unknown, {dataLoader}) => { - return meetingId ? dataLoader.get('newMeetings').load(meetingId) : null - } - } - }) -}) - -export default AddAgendaItemPayload diff --git a/packages/server/graphql/types/CreateAgendaItemInput.ts b/packages/server/graphql/types/CreateAgendaItemInput.ts deleted file mode 100644 index 35a9e121f2a..00000000000 --- a/packages/server/graphql/types/CreateAgendaItemInput.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { - GraphQLBoolean, - GraphQLFloat, - GraphQLID, - GraphQLInputObjectType, - GraphQLNonNull, - GraphQLString -} from 'graphql' - -export type CreateAgendaItemInputType = { - content: string - pinned: boolean - teamId: string - teamMemberId: string - sortOrder?: number - meetingId?: string -} - -const CreateAgendaItemInput = new GraphQLInputObjectType({ - name: 'CreateAgendaItemInput', - fields: () => ({ - content: { - type: new GraphQLNonNull(GraphQLString), - description: 'The content of the agenda item' - }, - pinned: { - type: new GraphQLNonNull(GraphQLBoolean), - description: 'True if the agenda item has been pinned' - }, - teamId: { - type: new GraphQLNonNull(GraphQLID) - }, - teamMemberId: { - type: new GraphQLNonNull(GraphQLID), - description: 'The team member ID of the person creating the agenda item' - }, - sortOrder: { - type: GraphQLFloat, - description: 'The sort order of the agenda item in the list' - }, - meetingId: { - type: GraphQLString, - description: 'The meeting ID of the agenda item' - } - }) -}) - -export default CreateAgendaItemInput diff --git a/packages/server/graphql/types/RemoveAgendaItemPayload.ts b/packages/server/graphql/types/RemoveAgendaItemPayload.ts deleted file mode 100644 index 3e41c11a090..00000000000 --- a/packages/server/graphql/types/RemoveAgendaItemPayload.ts +++ /dev/null @@ -1,29 +0,0 @@ -import {GraphQLID, GraphQLObjectType} from 'graphql' -import {GQLContext} from '../graphql' -import AgendaItem from './AgendaItem' -import NewMeeting from './NewMeeting' -import StandardMutationError from './StandardMutationError' - -const RemoveAgendaItemPayload = new GraphQLObjectType({ - name: 'RemoveAgendaItemPayload', - fields: () => ({ - error: { - type: StandardMutationError - }, - agendaItem: { - type: AgendaItem - }, - meetingId: { - type: GraphQLID - }, - meeting: { - type: NewMeeting, - description: 'The meeting with the updated agenda item, if any', - resolve: ({meetingId}, _args: unknown, {dataLoader}) => { - return meetingId ? dataLoader.get('newMeetings').load(meetingId) : null - } - } - }) -}) - -export default RemoveAgendaItemPayload diff --git a/packages/server/graphql/types/UpdateAgendaItemInput.ts b/packages/server/graphql/types/UpdateAgendaItemInput.ts deleted file mode 100644 index 06f581c1407..00000000000 --- a/packages/server/graphql/types/UpdateAgendaItemInput.ts +++ /dev/null @@ -1,44 +0,0 @@ -import { - GraphQLBoolean, - GraphQLFloat, - GraphQLID, - GraphQLInputObjectType, - GraphQLNonNull, - GraphQLString -} from 'graphql' - -const UpdateAgendaItemInput = new GraphQLInputObjectType({ - name: 'UpdateAgendaItemInput', - fields: () => ({ - id: { - type: new GraphQLNonNull(GraphQLID), - description: 'The unique agenda item ID, composed of a teamId::shortid' - }, - content: { - type: GraphQLString, - description: 'The content of the agenda item' - }, - pinned: { - type: GraphQLBoolean, - description: 'True if agenda item has been pinned' - }, - isActive: { - type: GraphQLBoolean, - description: 'True if not processed or deleted' - }, - sortOrder: { - type: GraphQLFloat, - description: 'The sort order of the agenda item in the list' - } - }) -}) - -export type UpdateAgendaItemInputType = { - id: string - content?: string | null - pinned?: boolean - isActive?: boolean | null - sortOrder?: number | null -} - -export default UpdateAgendaItemInput diff --git a/packages/server/graphql/types/UpdateAgendaItemPayload.ts b/packages/server/graphql/types/UpdateAgendaItemPayload.ts deleted file mode 100644 index d10a6e6155c..00000000000 --- a/packages/server/graphql/types/UpdateAgendaItemPayload.ts +++ /dev/null @@ -1,32 +0,0 @@ -import {GraphQLID, GraphQLObjectType} from 'graphql' -import {GQLContext} from '../graphql' -import AgendaItem from './AgendaItem' -import NewMeeting from './NewMeeting' -import StandardMutationError from './StandardMutationError' - -const UpdateAgendaItemPayload = new GraphQLObjectType({ - name: 'UpdateAgendaItemPayload', - fields: () => ({ - agendaItem: { - type: AgendaItem, - resolve: ({agendaItemId}, _args: unknown, {dataLoader}) => { - return dataLoader.get('agendaItems').load(agendaItemId) - } - }, - meetingId: { - type: GraphQLID - }, - meeting: { - type: NewMeeting, - description: 'The meeting with the updated agenda item, if any', - resolve: ({meetingId}, _args: unknown, {dataLoader}) => { - return meetingId ? dataLoader.get('newMeetings').load(meetingId) : null - } - }, - error: { - type: StandardMutationError - } - }) -}) - -export default UpdateAgendaItemPayload diff --git a/packages/server/postgres/migrations/1723672980596_AgendaItem-phase2.ts b/packages/server/postgres/migrations/1723672980596_AgendaItem-phase2.ts new file mode 100644 index 00000000000..42a199bba9f --- /dev/null +++ b/packages/server/postgres/migrations/1723672980596_AgendaItem-phase2.ts @@ -0,0 +1,184 @@ +import {Kysely, PostgresDialect, sql} from 'kysely' +import {r} from 'rethinkdb-ts' +import connectRethinkDB from '../../database/connectRethinkDB' +import getPg from '../getPg' + +const START_CHAR_CODE = 32 +const END_CHAR_CODE = 126 + +export function positionAfter(pos: string) { + for (let i = pos.length - 1; i >= 0; i--) { + const curCharCode = pos.charCodeAt(i) + if (curCharCode < END_CHAR_CODE) { + return pos.substr(0, i) + String.fromCharCode(curCharCode + 1) + } + } + return pos + String.fromCharCode(START_CHAR_CODE + 1) +} + +export async function up() { + await connectRethinkDB() + const pg = new Kysely({ + dialect: new PostgresDialect({ + pool: getPg() + }) + }) + + // add a dummy date for nulls + const parabolEpoch = new Date('2016-06-01') + await r + .table('AgendaItem') + .update((row) => ({ + updatedAt: row('updatedAt').default(parabolEpoch), + createdAt: row('createdAt').default(parabolEpoch) + })) + .run() + const strDates = await r + .table('AgendaItem') + .filter((row) => row('updatedAt').typeOf().eq('STRING')) + .pluck('updatedAt', 'id', 'createdAt') + .run() + const dateDates = strDates.map((d) => ({ + id: d.id, + updatedAt: new Date(d.updatedAt), + createdAt: new Date(d.createdAt) + })) + // some dates are + await r(dateDates) + .forEach((row: any) => { + return r + .table('AgendaItem') + .get(row('id')) + .update({updatedAt: row('updatedAt')}) + }) + .run() + + try { + console.log('Adding index') + await r + .table('AgendaItem') + .indexCreate('updatedAtId', (row: any) => [row('updatedAt'), row('id')]) + .run() + await r.table('AgendaItem').indexWait().run() + } catch { + // index already exists + } + + console.log('Adding index complete') + const MAX_PG_PARAMS = 65545 + const PG_COLS = [ + 'id', + 'content', + 'createdAt', + 'isActive', + 'isComplete', + 'sortOrder', + 'teamId', + 'teamMemberId', + 'updatedAt', + 'meetingId', + 'pinned', + 'pinnedParentId' + ] as const + type AgendaItem = { + [K in (typeof PG_COLS)[number]]: any + } + const BATCH_SIZE = Math.trunc(MAX_PG_PARAMS / PG_COLS.length) + + let curUpdatedAt = r.minval + let curId = r.minval + for (let i = 0; i < 1e6; i++) { + console.log('inserting row', i * BATCH_SIZE, String(curUpdatedAt), String(curId)) + const rawRowsToInsert = (await r + .table('AgendaItem') + .between([curUpdatedAt, curId], [r.maxval, r.maxval], { + index: 'updatedAtId', + leftBound: 'open', + rightBound: 'closed' + }) + .orderBy({index: 'updatedAtId'}) + .limit(BATCH_SIZE) + .pluck(...PG_COLS) + .run()) as AgendaItem[] + + const rowsToInsert = rawRowsToInsert.map((row) => { + const {sortOrder, ...rest} = row as any + return { + ...rest, + sortOrder: String(sortOrder) + } + }) + if (rowsToInsert.length === 0) break + const lastRow = rowsToInsert[rowsToInsert.length - 1] + curUpdatedAt = lastRow.updatedAt + curId = lastRow.id + try { + await pg + .insertInto('AgendaItem') + .values(rowsToInsert) + .onConflict((oc) => oc.doNothing()) + .execute() + } catch (e) { + await Promise.all( + rowsToInsert.map(async (row) => { + try { + await pg + .insertInto('AgendaItem') + .values(row) + .onConflict((oc) => oc.doNothing()) + .execute() + } catch (e) { + if (e.constraint === 'fk_teamMemberId' || e.constraint === 'fk_teamId') { + console.log(`Skipping ${row.id} because it has no user/team`) + return + } + console.log(e, row) + } + }) + ) + } + } + + // remap the sortOrder in PG because rethinkdb is too slow to group + const pgRows = await sql<{items: {sortOrder: string; id: string}[]}>` + select jsonb_agg(jsonb_build_object('sortOrder', "sortOrder", 'id', "id", 'meetingId', "meetingId", 'teamId', "teamId") ORDER BY "sortOrder") items from "AgendaItem" +group by "teamId", "meetingId";`.execute(pg) + + const groups = pgRows.rows.map((row) => { + const {items} = row + let curSortOrder = '' + for (let i = 0; i < items.length; i++) { + const item = items[i] + curSortOrder = positionAfter(curSortOrder) + item.sortOrder = curSortOrder + } + return row + }) + for (let i = 0; i < groups.length; i++) { + const group = groups[i] + await Promise.all( + group.items.map((item) => { + return pg + .updateTable('AgendaItem') + .set({sortOrder: item.sortOrder}) + .where('id', '=', item.id) + .execute() + }) + ) + } +} + +export async function down() { + // await connectRethinkDB() + // try { + // await r.table('AgendaItem').indexDrop('updatedAtId').run() + // } catch { + // // index already dropped + // } + const pg = new Kysely({ + dialect: new PostgresDialect({ + pool: getPg() + }) + }) + await sql`TRUNCATE TABLE "AgendaItem" CASCADE`.execute(pg) +} diff --git a/packages/server/postgres/types/index.d.ts b/packages/server/postgres/types/index.d.ts index f6706233236..ca262a72b5e 100644 --- a/packages/server/postgres/types/index.d.ts +++ b/packages/server/postgres/types/index.d.ts @@ -6,6 +6,7 @@ import { TeamMember as TeamMemberPG } from '../pg.d' import { + selectAgendaItems, selectMeetingSettings, selectOrganizations, selectRetroReflections, @@ -44,3 +45,5 @@ export type TemplateScaleRef = ExtractTypeFromQueryBuilderSelect export type PokerMeetingSettings = MeetingSettings & {meetingType: 'poker'} export type RetrospectiveMeetingSettings = MeetingSettings & {meetingType: 'retrospective'} + +export type AgendaItem = ExtractTypeFromQueryBuilderSelect