diff --git a/codegen.json b/codegen.json index 53c0d8bdd28..27ff63a820f 100644 --- a/codegen.json +++ b/codegen.json @@ -13,11 +13,9 @@ "mappers": { "AddFeatureFlagSuccess": "./types/AddFeatureFlagSuccess#AddFeatureFlagSuccessSource", "ApplyFeatureFlagSuccess": "./types/ApplyFeatureFlagSuccess#ApplyFeatureFlagSuccessSource", - "RemoveFeatureFlagOwnerSuccess": "./types/RemoveFeatureFlagOwnerSuccess#RemoveFeatureFlagOwnerSuccessSource", - "DeleteFeatureFlagSuccess": "./types/DeleteFeatureFlagSuccess#DeleteFeatureFlagSuccessSource", - "UpdateFeatureFlagSuccess": "./types/UpdateFeatureFlagSuccess#UpdateFeatureFlagSuccessSource", "ChangeEmailDomainSuccess": "./types/ChangeEmailDomainSuccess#ChangeEmailDomainSuccessSource", "Company": "./queries/company#CompanySource", + "DeleteFeatureFlagSuccess": "./types/DeleteFeatureFlagSuccess#DeleteFeatureFlagSuccessSource", "DraftEnterpriseInvoicePayload": "./types/DraftEnterpriseInvoicePayload#DraftEnterpriseInvoicePayloadSource", "EndTrialSuccess": "./types/EndTrialSuccess#EndTrialSuccessSource", "File": "../public/types/File#TFile", @@ -32,6 +30,7 @@ "PingableServices": "./types/PingableServices#PingableServicesSource", "ProcessRecurrenceSuccess": "./types/ProcessRecurrenceSuccess#ProcessRecurrenceSuccessSource", "RemoveAuthIdentitySuccess": "./types/RemoveAuthIdentitySuccess#RemoveAuthIdentitySuccessSource", + "RemoveFeatureFlagOwnerSuccess": "./types/RemoveFeatureFlagOwnerSuccess#RemoveFeatureFlagOwnerSuccessSource", "RetrospectiveMeeting": "../../postgres/types/Meeting#RetrospectiveMeeting", "SAML": "./types/SAML#SAMLSource", "SetIsFreeMeetingTemplateSuccess": "./types/SetIsFreeMeetingTemplateSuccess#SetIsFreeMeetingTemplateSuccessSource", @@ -39,6 +38,7 @@ "StartTrialSuccess": "./types/StartTrialSuccess#StartTrialSuccessSource", "StripeFailPaymentPayload": "./mutations/stripeFailPayment#StripeFailPaymentPayloadSource", "Team": "../../postgres/types/index#Team as TeamDB", + "UpdateFeatureFlagSuccess": "./types/UpdateFeatureFlagSuccess#UpdateFeatureFlagSuccessSource", "UpgradeToTeamTierSuccess": "./mutations/upgradeToTeamTier#UpgradeToTeamTierSuccessSource", "User": "../../postgres/types/IUser#default as IUser", "VerifyDomainSuccess": "./types/VerifyDomainSuccess#VerifyDomainSuccessSource" @@ -51,40 +51,19 @@ "config": { "contextType": "../graphql#GQLContext", "mappers": { - "TaskEstimate": "../../postgres/types/index#TaskEstimate", - "ReflectTemplatePromptUpdateDescriptionPayload": "./types/ReflectTemplatePromptUpdateDescriptionPayload#ReflectTemplatePromptUpdateDescriptionPayloadSource", - "NewFeatureBroadcast": "../../postgres/types/index#NewFeature", - "ReflectTemplatePromptUpdateGroupColorPayload": "./types/ReflectTemplatePromptUpdateGroupColorPayload#ReflectTemplatePromptUpdateGroupColorPayloadSource", - "RemoveReflectTemplatePromptPayload": "./types/RemoveReflectTemplatePromptPayload#RemoveReflectTemplatePromptPayloadSource", - "RenameReflectTemplatePromptPayload": "./types/RenameReflectTemplatePromptPayload#RenameReflectTemplatePromptPayloadSource", - "MoveReflectTemplatePromptPayload": "./types/MoveReflectTemplatePromptPayload#MoveReflectTemplatePromptPayloadSource", - "AddReflectTemplatePromptPayload": "./types/AddReflectTemplatePromptPayload#AddReflectTemplatePromptPayloadSource", - "SetSlackNotificationPayload": "./types/SetSlackNotificationPayload#SetSlackNotificationPayloadSource", - "SetDefaultSlackChannelSuccess": "./types/SetDefaultSlackChannelSuccess#SetDefaultSlackChannelSuccessSource", - "AddCommentSuccess": "./types/AddCommentSuccess#AddCommentSuccessSource", - "AddIntegrationProviderSuccess": "./types/AddIntegrationProviderSuccess#AddIntegrationProviderSuccessSource", - "DeleteCommentSuccess": "./types/DeleteCommentSuccess#DeleteCommentSuccessSource", - "UpdateCommentContentSuccess": "./types/UpdateCommentContentSuccess#UpdateCommentContentSuccessSource", - "AddSlackAuthPayload": "./types/AddSlackAuthPayload#AddSlackAuthPayloadSource", - "RemoveAgendaItemPayload": "./types/RemoveAgendaItemPayload#RemoveAgendaItemPayloadSource", - "AddAgendaItemPayload": "./types/AddAgendaItemPayload#AddAgendaItemPayloadSource", - "UpdateAgendaItemPayload": "./types/UpdateAgendaItemPayload#UpdateAgendaItemPayloadSource", - "TeamMeetingSettings": "../../postgres/types/index#MeetingSettings as TeamMeetingSettingsDB", - "PokerMeetingSettings": "../../postgres/types/index#PokerMeetingSettings as PokerMeetingSettingsDB", - "RetrospectiveMeetingSettings": "../../postgres/types/index#RetrospectiveMeetingSettings as RetrospectiveMeetingSettingsDB", - "RemovePokerTemplatePayload": "./types/RemovePokerTemplatePayload#RemovePokerTemplatePayloadSource", - "JiraRemoteAvatarUrls": "./types/JiraRemoteAvatarUrls#JiraRemoteAvatarUrlsSource", - "TemplateDimensionRef": "./types/TemplateDimensionRef#TemplateDimensionRefSource", - "UpdateIntegrationProviderSuccess": "./types/UpdateIntegrationProviderSuccess#UpdateIntegrationProviderSuccessSource", - "EndTeamPromptSuccess": "./types/EndTeamPromptSuccess#EndTeamPromptSuccessSource", "AcceptRequestToJoinDomainSuccess": "./types/AcceptRequestToJoinDomainSuccess#AcceptRequestToJoinDomainSuccessSource", "AcceptTeamInvitationPayload": "./types/AcceptTeamInvitationPayload#AcceptTeamInvitationPayloadSource", "ActionMeeting": "../../postgres/types/Meeting#CheckInMeeting", "ActionMeetingMember": "../../postgres/types/Meeting.d#ActionMeetingMember as ActionMeetingMemberDB", + "AddAgendaItemPayload": "./types/AddAgendaItemPayload#AddAgendaItemPayloadSource", "AddApprovedOrganizationDomainsSuccess": "./types/AddApprovedOrganizationDomainsSuccess#AddApprovedOrganizationDomainsSuccessSource", + "AddCommentSuccess": "./types/AddCommentSuccess#AddCommentSuccessSource", + "AddIntegrationProviderSuccess": "./types/AddIntegrationProviderSuccess#AddIntegrationProviderSuccessSource", "AddPokerTemplateSuccess": "./types/AddPokerTemplateSuccess#AddPokerTemplateSuccessSource", "AddReactjiToReactableSuccess": "./types/AddReactjiToReactableSuccess#AddReactjiToReactableSuccessSource", + "AddReflectTemplatePromptPayload": "./types/AddReflectTemplatePromptPayload#AddReflectTemplatePromptPayloadSource", "AddReflectTemplateSuccess": "./types/AddReflectTemplateSuccess#AddReflectTemplateSuccessSource", + "AddSlackAuthPayload": "./types/AddSlackAuthPayload#AddSlackAuthPayloadSource", "AddTeamMemberIntegrationAuthSuccess": "./types/AddTeamMemberIntegrationAuthPayload#AddTeamMemberIntegrationAuthSuccessSource", "AddTranscriptionBotSuccess": "./types/AddTranscriptionBotSuccess#AddTranscriptionBotSuccessSource", "AddedNotification": "./types/AddedNotification#AddedNotificationSource", @@ -103,8 +82,10 @@ "CreateImposterTokenPayload": "./types/CreateImposterTokenPayload#CreateImposterTokenPayloadSource", "CreateStripeSubscriptionSuccess": "./types/CreateStripeSubscriptionSuccess#CreateStripeSubscriptionSuccessSource", "CreateTaskPayload": "./types/CreateTaskPayload#CreateTaskPayloadSource", + "DeleteCommentSuccess": "./types/DeleteCommentSuccess#DeleteCommentSuccessSource", "Discussion": "../../postgres/queries/generated/getDiscussionsByIdsQuery#IGetDiscussionsByIdsQueryResult", "DomainJoinRequest": "../../database/types/DomainJoinRequest#default as DomainJoinRequestDB", + "EndTeamPromptSuccess": "./types/EndTeamPromptSuccess#EndTeamPromptSuccessSource", "File": "./types/File#TFile", "GcalIntegration": "./types/GcalIntegration#GcalIntegrationSource", "GenerateGroupsSuccess": "./types/GenerateGroupsSuccess#GenerateGroupsSuccessSource", @@ -117,6 +98,7 @@ "IntegrationProviderWebhook": "../../postgres/queries/getIntegrationProvidersByIds#TIntegrationProvider", "InviteToTeamPayload": "./types/InviteToTeamPayload#InviteToTeamPayloadSource", "JiraIssue": "./types/JiraIssue#JiraIssueSource", + "JiraRemoteAvatarUrls": "./types/JiraRemoteAvatarUrls#JiraRemoteAvatarUrlsSource", "JiraRemoteProject": "./types/JiraRemoteProject#JiraRemoteProjectSource", "JiraSearchQuery": "../../database/types/JiraSearchQuery#default as JiraSearchQueryDB", "JiraServerIntegration": "./types/JiraServerIntegration#JiraServerIntegrationSource", @@ -126,6 +108,8 @@ "MattermostIntegration": "./types/MattermostIntegration#MattermostIntegrationSource", "MeetingSeries": "../../postgres/types/MeetingSeries#MeetingSeries", "MeetingTemplate": "../../database/types/MeetingTemplate#default", + "MoveReflectTemplatePromptPayload": "./types/MoveReflectTemplatePromptPayload#MoveReflectTemplatePromptPayloadSource", + "NewFeatureBroadcast": "../../postgres/types/index#NewFeature", "NewMeeting": "../../postgres/types/Meeting#AnyMeeting", "NewMeetingPhase": "./types/NewMeetingPhase#NewMeetingPhaseSource", "NewMeetingStage": "./types/NewMeetingStage#NewMeetingStageSource", @@ -137,44 +121,49 @@ "NotifyMentioned": "../../postgres/types/Notification.d#MentionedNotification as MentionedNotificationDB", "NotifyPaymentRejected": "../../postgres/types/Notification.d#PaymentRejectedNotification as PaymentRejectedNotificationDB", "NotifyPromoteToOrgLeader": "../../postgres/types/Notification.d#PromoteToBillingLeaderNotification as PromoteToBillingLeaderNotificationDB", + "NotifyPromptToJoinOrg": "../../postgres/types/Notification.d#PromptToJoinOrgNotification as PromptToJoinOrgNotificationDB", "NotifyRequestToJoinOrg": "../../postgres/types/Notification.d#RequestToJoinOrgNotification as RequestToJoinOrgNotificationDB", "NotifyResponseMentioned": "../../postgres/types/Notification.d#ResponseMentionedNotification as ResponseMentionedNotificationDB", "NotifyResponseReplied": "../../postgres/types/Notification.d#ResponseRepliedNotification as ResponseRepliedNotificationDB", "NotifyTaskInvolves": "../../postgres/types/Notification.d#TaskInvolvesNotification as TaskInvolvesNotificationDB", "NotifyTeamArchived": "../../postgres/types/Notification.d#TeamArchivedNotification as TeamArchivedNotificationDB", - "NotifyTeamsLimitReminder": "../../postgres/types/Notification.d#TeamsLimitReminderNotification as TeamsLimitReminderNotificationDB", "NotifyTeamsLimitExceeded": "../../postgres/types/Notification.d#TeamsLimitExceededNotification as TeamsLimitExceededNotificationDB", - "NotifyPromptToJoinOrg": "../../postgres/types/Notification.d#PromptToJoinOrgNotification as PromptToJoinOrgNotificationDB", - "Organization": "../../postgres/types/index#Organization as OrganizationDB", - "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", + "NotifyTeamsLimitReminder": "../../postgres/types/Notification.d#TeamsLimitReminderNotification as TeamsLimitReminderNotificationDB", "OrgIntegrationProviders": "./types/OrgIntegrationProviders#OrgIntegrationProvidersSource", + "Organization": "../../postgres/types/index#Organization as OrganizationDB", "OrganizationUser": "../../postgres/types/index#OrganizationUser as OrganizationUserDB", "PokerMeeting": "../../postgres/types/Meeting#PokerMeeting", "PokerMeetingMember": "../../postgres/types/Meeting.d#PokerMeetingMember as PokerMeetingMemberDB", + "PokerMeetingSettings": "../../postgres/types/index#PokerMeetingSettings as PokerMeetingSettingsDB", "PokerTemplate": "../../database/types/PokerTemplate#default as PokerTemplateDB", "RRule": "rrule-rust#RRuleSet", "Reactable": "../../database/types/Reactable#Reactable", "Reactji": "../types/Reactji#ReactjiSource", "ReflectPrompt": "../../postgres/types/index#ReflectPrompt", "ReflectTemplate": "../../database/types/ReflectTemplate#default", + "ReflectTemplatePromptUpdateDescriptionPayload": "./types/ReflectTemplatePromptUpdateDescriptionPayload#ReflectTemplatePromptUpdateDescriptionPayloadSource", + "ReflectTemplatePromptUpdateGroupColorPayload": "./types/ReflectTemplatePromptUpdateGroupColorPayload#ReflectTemplatePromptUpdateGroupColorPayloadSource", + "RemoveAgendaItemPayload": "./types/RemoveAgendaItemPayload#RemoveAgendaItemPayloadSource", "RemoveApprovedOrganizationDomainsSuccess": "./types/RemoveApprovedOrganizationDomainsSuccess#RemoveApprovedOrganizationDomainsSuccessSource", "RemoveIntegrationSearchQuerySuccess": "./types/RemoveIntegrationSearchQuerySuccess#RemoveIntegrationSearchQuerySuccessSource", + "RemovePokerTemplatePayload": "./types/RemovePokerTemplatePayload#RemovePokerTemplatePayloadSource", + "RemoveReflectTemplatePromptPayload": "./types/RemoveReflectTemplatePromptPayload#RemoveReflectTemplatePromptPayloadSource", "RemoveTeamMemberIntegrationAuthSuccess": "./types/RemoveTeamMemberIntegrationAuthPayload#RemoveTeamMemberIntegrationAuthSuccessSource", "RemoveTeamMemberPayload": "./types/RemoveTeamMemberPayload#RemoveTeamMemberPayloadSource", + "RenameReflectTemplatePromptPayload": "./types/RenameReflectTemplatePromptPayload#RenameReflectTemplatePromptPayloadSource", "RequestToJoinDomainSuccess": "./types/RequestToJoinDomainSuccess#RequestToJoinDomainSuccessSource", "ResetReflectionGroupsSuccess": "./types/ResetReflectionGroupsSuccess#ResetReflectionGroupsSuccessSource", "RetroReflection": "../../postgres/types/index#RetroReflection as RetroReflectionDB", "RetroReflectionGroup": "./types/RetroReflectionGroup#RetroReflectionGroupSource", "RetrospectiveMeeting": "../../postgres/types/Meeting#RetrospectiveMeeting", "RetrospectiveMeetingMember": "../../postgres/types/Meeting.d#RetroMeetingMember as RetroMeetingMemberDB", + "RetrospectiveMeetingSettings": "../../postgres/types/index#RetrospectiveMeetingSettings as RetrospectiveMeetingSettingsDB", "SAML": "./types/SAML#SAMLSource", + "SetDefaultSlackChannelSuccess": "./types/SetDefaultSlackChannelSuccess#SetDefaultSlackChannelSuccessSource", "SetMeetingSettingsPayload": "../types/SetMeetingSettingsPayload#SetMeetingSettingsPayloadSource", "SetNotificationStatusPayload": "./types/SetNotificationStatusPayload#SetNotificationStatusPayloadSource", "SetOrgUserRoleSuccess": "./types/SetOrgUserRoleSuccess#SetOrgUserRoleSuccessSource", + "SetSlackNotificationPayload": "./types/SetSlackNotificationPayload#SetSlackNotificationPayloadSource", "ShareTopicSuccess": "./types/ShareTopicSuccess#ShareTopicSuccessSource", "SlackIntegration": "../../postgres/types/index#SlackAuth as SlackAuthDB", "SlackNotification": "../../postgres/types/index#SlackNotification as SlackNotificationDB", @@ -182,11 +171,14 @@ "StartRetrospectiveSuccess": "./types/StartRetrospectiveSuccess#StartRetrospectiveSuccessSource", "StartTeamPromptSuccess": "./types/StartTeamPromptSuccess#StartTeamPromptSuccessSource", "StripeFailPaymentPayload": "./types/StripeFailPaymentPayload#StripeFailPaymentPayloadSource", + "SuggestedAction": "../../postgres/types/index#SuggestedAction as SuggestedActionDB", "Task": "../../postgres/types/index#Task as TaskDB", + "TaskEstimate": "../../postgres/types/index#TaskEstimate", "Team": "../../postgres/types/index#Team as TeamDB", "TeamHealthPhase": "./types/TeamHealthPhase#TeamHealthPhaseSource", "TeamHealthStage": "./types/TeamHealthStage#TeamHealthStageSource", "TeamInvitation": "../../postgres/types/index/#TeamInvitation", + "TeamMeetingSettings": "../../postgres/types/index#MeetingSettings as TeamMeetingSettingsDB", "TeamMember": "../../postgres/types/index#TeamMember as TeamMember", "TeamMemberIntegrationAuthOAuth1": "../../postgres/queries/getTeamMemberIntegrationAuth#TeamMemberIntegrationAuth", "TeamMemberIntegrationAuthOAuth2": "../../postgres/queries/getTeamMemberIntegrationAuth#TeamMemberIntegrationAuth", @@ -197,13 +189,23 @@ "TeamPromptMeetingSettings": "../../postgres/types/index#MeetingSettings as TeamMeetingSettingsDB", "TeamPromptResponse": "../../postgres/types/index#TeamPromptResponse as TeamPromptResponseDB", "TemplateDimension": "../../postgres/types/index#TemplateDimension as TemplateDimensionDB", + "TemplateDimensionRef": "./types/TemplateDimensionRef#TemplateDimensionRefSource", + "TemplateScale": "../../postgres/types/index#TemplateScale as TemplateScaleDB", + "TemplateScaleRef": "../../postgres/types/index#TemplateScaleRef as TemplateScaleRefDB", + "TemplateScaleValue": "./types/TemplateScaleValue#TemplateScaleValueSource as TemplateScaleValueSourceDB", + "Threadable": "./types/Threadable#ThreadableSource", "TimelineEventTeamPromptComplete": "./types/TimelineEventTeamPromptComplete#TimelineEventTeamPromptCompleteSource", + "ToggleAIFeaturesSuccess": "./types/ToggleAIFeaturesSuccess#ToggleAIFeaturesSuccessSource", "ToggleFavoriteTemplateSuccess": "./types/ToggleFavoriteTemplateSuccess#ToggleFavoriteTemplateSuccessSource", + "ToggleFeatureFlagSuccess": "./types/ToggleFeatureFlagSuccess#ToggleFeatureFlagSuccessSource", "ToggleSummaryEmailSuccess": "./types/ToggleSummaryEmailSuccess#ToggleSummaryEmailSuccessSource", + "UpdateAgendaItemPayload": "./types/UpdateAgendaItemPayload#UpdateAgendaItemPayloadSource", "UpdateAutoJoinSuccess": "./types/UpdateAutoJoinSuccess#UpdateAutoJoinSuccessSource", + "UpdateCommentContentSuccess": "./types/UpdateCommentContentSuccess#UpdateCommentContentSuccessSource", "UpdateCreditCardSuccess": "./types/UpdateCreditCardSuccess#UpdateCreditCardSuccessSource", "UpdateDimensionFieldSuccess": "./types/UpdateDimensionFieldSuccess#UpdateDimensionFieldSuccessSource", "UpdateGitLabDimensionFieldSuccess": "./types/UpdateGitLabDimensionFieldSuccess#UpdateGitLabDimensionFieldSuccessSource", + "UpdateIntegrationProviderSuccess": "./types/UpdateIntegrationProviderSuccess#UpdateIntegrationProviderSuccessSource", "UpdateMeetingPromptSuccess": "./types/UpdateMeetingPromptSuccess#UpdateMeetingPromptSuccessSource", "UpdateMeetingTemplateSuccess": "./types/UpdateMeetingTemplateSuccess#UpdateMeetingTemplateSuccessSource", "UpdateOrgPayload": "./types/UpdateOrgPayload#UpdateOrgPayloadSource", diff --git a/packages/client/modules/email/components/SummaryEmail/MeetingSummaryEmail/WholeMeetingSummary.tsx b/packages/client/modules/email/components/SummaryEmail/MeetingSummaryEmail/WholeMeetingSummary.tsx index 4d98482b5f4..84af160d835 100644 --- a/packages/client/modules/email/components/SummaryEmail/MeetingSummaryEmail/WholeMeetingSummary.tsx +++ b/packages/client/modules/email/components/SummaryEmail/MeetingSummaryEmail/WholeMeetingSummary.tsx @@ -10,7 +10,7 @@ interface Props { } const isServer = typeof window === 'undefined' -const hasAI = isServer +const hasAiApiKey = isServer ? !!process.env.OPEN_AI_API_KEY : !!window.__ACTION__ && !!window.__ACTION__.hasOpenAI @@ -25,7 +25,7 @@ const WholeMeetingSummary = (props: Props) => { summary organization { hasStandupAISummaryFlag: featureFlag(featureName: "standupAISummary") - hasNoAISummaryFlag: featureFlag(featureName: "noAISummary") + useAI } ... on RetrospectiveMeeting { reflectionGroups(sortBy: voteCount) { @@ -47,15 +47,16 @@ const WholeMeetingSummary = (props: Props) => { const {summary: wholeMeetingSummary, reflectionGroups, organization} = meeting const reflections = reflectionGroups?.flatMap((group) => group.reflections) // reflectionCount hasn't been calculated yet so check reflections length const hasMoreThanOneReflection = reflections?.length && reflections.length > 1 - if (!hasMoreThanOneReflection || organization.hasNoAISummaryFlag || !hasAI) return null + if (!hasMoreThanOneReflection || !organization.useAI || !hasAiApiKey) return null if (!wholeMeetingSummary) return return } else if (meeting.__typename === 'TeamPromptMeeting') { const {summary: wholeMeetingSummary, responses, organization} = meeting + const {hasStandupAISummaryFlag, useAI} = organization if ( - !organization.hasStandupAISummaryFlag || - organization.hasNoAISummaryFlag || - !hasAI || + !hasStandupAISummaryFlag || + !useAI || + !hasAiApiKey || !responses || responses.length === 0 ) { diff --git a/packages/client/modules/meeting/components/MeetingCheckInPrompt/NewCheckInQuestion.tsx b/packages/client/modules/meeting/components/MeetingCheckInPrompt/NewCheckInQuestion.tsx index a7b1294e39c..a4606299f98 100644 --- a/packages/client/modules/meeting/components/MeetingCheckInPrompt/NewCheckInQuestion.tsx +++ b/packages/client/modules/meeting/components/MeetingCheckInPrompt/NewCheckInQuestion.tsx @@ -87,7 +87,7 @@ const NewCheckInQuestion = (props: Props) => { } team { organization { - hasNoAISummaryFlag: featureFlag(featureName: "noAISummary") + useAI } } } @@ -101,7 +101,7 @@ const NewCheckInQuestion = (props: Props) => { localPhase, facilitatorUserId, team: { - organization: {hasNoAISummaryFlag} + organization: {useAI} } } = meeting const {checkInQuestion} = localPhase @@ -226,7 +226,7 @@ const NewCheckInQuestion = (props: Props) => { } }) } - const showAiIcebreaker = !hasNoAISummaryFlag && isFacilitating && window.__ACTION__.hasOpenAI + const showAiIcebreaker = useAI && isFacilitating && window.__ACTION__.hasOpenAI return ( <> diff --git a/packages/client/modules/userDashboard/components/OrgBilling/OrgDetails.tsx b/packages/client/modules/userDashboard/components/OrgBilling/OrgDetails.tsx index 6784b5bb14b..eeaf0bbb63b 100644 --- a/packages/client/modules/userDashboard/components/OrgBilling/OrgDetails.tsx +++ b/packages/client/modules/userDashboard/components/OrgBilling/OrgDetails.tsx @@ -10,6 +10,8 @@ import useModal from '../../../../hooks/useModal' import defaultOrgAvatar from '../../../../styles/theme/images/avatar-organization.svg' import OrganizationDetails from '../Organization/OrganizationDetails' import OrgBillingDangerZone from './OrgBillingDangerZone' +import OrgFeatureFlags from './OrgFeatureFlags' +import OrgFeatures from './OrgFeatures' type Props = { organizationRef: OrgDetails_organization$key @@ -22,6 +24,8 @@ const OrgDetails = (props: Props) => { fragment OrgDetails_organization on Organization { ...OrgBillingDangerZone_organization ...EditableOrgName_organization + ...OrgFeatureFlags_organization + ...OrgFeatures_organization orgId: id isBillingLeader createdAt @@ -66,6 +70,9 @@ const OrgDetails = (props: Props) => { + + + ) diff --git a/packages/client/modules/userDashboard/components/OrgBilling/OrgFeatureFlags.tsx b/packages/client/modules/userDashboard/components/OrgBilling/OrgFeatureFlags.tsx new file mode 100644 index 00000000000..1be1901bb02 --- /dev/null +++ b/packages/client/modules/userDashboard/components/OrgBilling/OrgFeatureFlags.tsx @@ -0,0 +1,111 @@ +import styled from '@emotion/styled' +import {Info as InfoIcon} from '@mui/icons-material' +import graphql from 'babel-plugin-relay/macro' +import React from 'react' +import {useFragment} from 'react-relay' +import {OrgFeatureFlags_organization$key} from '../../../../__generated__/OrgFeatureFlags_organization.graphql' +import Panel from '../../../../components/Panel/Panel' +import Toggle from '../../../../components/Toggle/Toggle' +import useAtmosphere from '../../../../hooks/useAtmosphere' +import useMutationProps from '../../../../hooks/useMutationProps' +import ToggleFeatureFlagMutation from '../../../../mutations/ToggleFeatureFlagMutation' +import {PALETTE} from '../../../../styles/paletteV3' +import {ElementWidth, Layout} from '../../../../types/constEnums' +import {Tooltip} from '../../../../ui/Tooltip/Tooltip' +import {TooltipContent} from '../../../../ui/Tooltip/TooltipContent' +import {TooltipTrigger} from '../../../../ui/Tooltip/TooltipTrigger' + +const StyledPanel = styled(Panel)<{isWide: boolean}>(({isWide}) => ({ + maxWidth: isWide ? ElementWidth.PANEL_WIDTH : 'inherit' +})) + +const PanelRow = styled('div')({ + borderTop: `1px solid ${PALETTE.SLATE_300}`, + padding: Layout.ROW_GUTTER +}) + +const FeatureRow = styled('div')({ + display: 'flex', + justifyContent: 'space-between', + alignItems: 'center', + marginBottom: 8 +}) + +const FeatureNameGroup = styled('div')({ + display: 'flex', + alignItems: 'center', + gap: 4, + '& svg': { + display: 'block' + } +}) + +// TODO: create a migration that updates featureName to be a readable string +// then update the references throughout the app and remove this +const FEATURE_NAME_LOOKUP: Record = { + insights: 'Team Insights', + publicTeams: 'Public Teams', + relatedDiscussions: 'Related Discussions', + standupAISummary: 'Standup AI Summary', + suggestGroups: 'AI Reflection Group Suggestions' +} + +interface Props { + organizationRef: OrgFeatureFlags_organization$key +} + +const OrgFeatureFlags = (props: Props) => { + const {organizationRef} = props + const atmosphere = useAtmosphere() + const {onError, onCompleted} = useMutationProps() + const organization = useFragment( + graphql` + fragment OrgFeatureFlags_organization on Organization { + id + isOrgAdmin + orgFeatureFlags { + featureName + description + enabled + } + } + `, + organizationRef + ) + const {isOrgAdmin} = organization + + const handleToggle = async (featureName: string) => { + const variables = { + featureName, + orgId: organization.id + } + ToggleFeatureFlagMutation(atmosphere, variables, { + onError, + onCompleted + }) + } + + if (!isOrgAdmin) return null + return ( + + + {organization.orgFeatureFlags.map((feature) => ( + + + {FEATURE_NAME_LOOKUP[feature.featureName] || feature.featureName} + + + + + {feature.description} + + + handleToggle(feature.featureName)} /> + + ))} + + + ) +} + +export default OrgFeatureFlags diff --git a/packages/client/modules/userDashboard/components/OrgBilling/OrgFeatures.tsx b/packages/client/modules/userDashboard/components/OrgBilling/OrgFeatures.tsx new file mode 100644 index 00000000000..c9937a882cb --- /dev/null +++ b/packages/client/modules/userDashboard/components/OrgBilling/OrgFeatures.tsx @@ -0,0 +1,92 @@ +import styled from '@emotion/styled' +import {Info as InfoIcon} from '@mui/icons-material' +import graphql from 'babel-plugin-relay/macro' +import React from 'react' +import {useFragment} from 'react-relay' +import {OrgFeatures_organization$key} from '../../../../__generated__/OrgFeatures_organization.graphql' +import Panel from '../../../../components/Panel/Panel' +import Toggle from '../../../../components/Toggle/Toggle' +import useAtmosphere from '../../../../hooks/useAtmosphere' +import useMutationProps from '../../../../hooks/useMutationProps' +import ToggleAIFeaturesMutation from '../../../../mutations/ToggleAIFeaturesMutation' +import {PALETTE} from '../../../../styles/paletteV3' +import {ElementWidth, Layout} from '../../../../types/constEnums' +import {Tooltip} from '../../../../ui/Tooltip/Tooltip' +import {TooltipContent} from '../../../../ui/Tooltip/TooltipContent' +import {TooltipTrigger} from '../../../../ui/Tooltip/TooltipTrigger' + +const StyledPanel = styled(Panel)<{isWide: boolean}>(({isWide}) => ({ + maxWidth: isWide ? ElementWidth.PANEL_WIDTH : 'inherit' +})) + +const PanelRow = styled('div')({ + borderTop: `1px solid ${PALETTE.SLATE_300}`, + padding: Layout.ROW_GUTTER +}) + +const FeatureRow = styled('div')({ + display: 'flex', + justifyContent: 'space-between', + alignItems: 'center', + marginBottom: 8 +}) + +const FeatureNameGroup = styled('div')({ + display: 'flex', + alignItems: 'center', + gap: 4, + '& svg': { + display: 'block' + } +}) + +interface Props { + organizationRef: OrgFeatures_organization$key +} + +const OrgFeatures = (props: Props) => { + const {organizationRef} = props + const atmosphere = useAtmosphere() + const {onError, onCompleted} = useMutationProps() + const organization = useFragment( + graphql` + fragment OrgFeatures_organization on Organization { + id + isOrgAdmin + useAI + } + `, + organizationRef + ) + const {id: orgId, isOrgAdmin, useAI} = organization + + const handleToggle = () => { + const variables = {orgId} + ToggleAIFeaturesMutation(atmosphere, variables, { + onError, + onCompleted + }) + } + + if (!isOrgAdmin) return null + return ( + + + + + Enable AI Features + + + + + Enable AI-powered features across your organization + + + + + + + ) +} + +export default OrgFeatures diff --git a/packages/client/mutations/EndRetrospectiveMutation.ts b/packages/client/mutations/EndRetrospectiveMutation.ts index 2f354021a96..9affc761b1f 100644 --- a/packages/client/mutations/EndRetrospectiveMutation.ts +++ b/packages/client/mutations/EndRetrospectiveMutation.ts @@ -32,7 +32,7 @@ graphql` groupTitle } organization { - hasNoAISummaryFlag: featureFlag(featureName: "noAISummary") + useAI } reflectionGroups(sortBy: voteCount) { reflections { @@ -125,7 +125,7 @@ export const endRetrospectiveTeamOnNext: OnNextHandler< const reflections = reflectionGroups.flatMap((group) => group.reflections) // reflectionCount hasn't been calculated yet so check reflections length const hasMoreThanOneReflection = reflections.length > 1 const hasOpenAISummary = - hasMoreThanOneReflection && !organization.hasNoAISummaryFlag && window.__ACTION__.hasOpenAI + hasMoreThanOneReflection && organization.useAI && window.__ACTION__.hasOpenAI const hasTeamHealth = phases.some((phase) => phase.phaseType === 'TEAM_HEALTH') const pathname = `/new-summary/${meetingId}` const search = new URLSearchParams() diff --git a/packages/client/mutations/ToggleAIFeaturesMutation.ts b/packages/client/mutations/ToggleAIFeaturesMutation.ts new file mode 100644 index 00000000000..c86b843218c --- /dev/null +++ b/packages/client/mutations/ToggleAIFeaturesMutation.ts @@ -0,0 +1,41 @@ +import graphql from 'babel-plugin-relay/macro' +import {commitMutation} from 'react-relay' +import {ToggleAIFeaturesMutation as TToggleAIFeaturesMutation} from '../__generated__/ToggleAIFeaturesMutation.graphql' +import {StandardMutation} from '../types/relayMutations' + +graphql` + fragment ToggleAIFeaturesMutation_organization on ToggleAIFeaturesSuccess { + organization { + id + useAI + } + } +` + +const mutation = graphql` + mutation ToggleAIFeaturesMutation($orgId: ID!) { + toggleAIFeatures(orgId: $orgId) { + ... on ErrorPayload { + error { + message + } + } + ...ToggleAIFeaturesMutation_organization @relay(mask: false) + } + } +` + +const ToggleAIFeaturesMutation: StandardMutation = ( + atmosphere, + variables, + {onError, onCompleted} +) => { + return commitMutation(atmosphere, { + mutation, + variables, + onCompleted, + onError + }) +} + +export default ToggleAIFeaturesMutation diff --git a/packages/client/mutations/ToggleFeatureFlagMutation.ts b/packages/client/mutations/ToggleFeatureFlagMutation.ts new file mode 100644 index 00000000000..33e1123f4e0 --- /dev/null +++ b/packages/client/mutations/ToggleFeatureFlagMutation.ts @@ -0,0 +1,41 @@ +import graphql from 'babel-plugin-relay/macro' +import {commitMutation} from 'react-relay' +import {ToggleFeatureFlagMutation as TToggleFeatureFlagMutation} from '../__generated__/ToggleFeatureFlagMutation.graphql' +import {StandardMutation} from '../types/relayMutations' + +graphql` + fragment ToggleFeatureFlagMutation_notification on ToggleFeatureFlagSuccess { + featureFlag { + featureName + enabled + } + } +` + +const mutation = graphql` + mutation ToggleFeatureFlagMutation($featureName: String!, $orgId: ID, $teamId: ID, $userId: ID) { + toggleFeatureFlag(featureName: $featureName, orgId: $orgId, teamId: $teamId, userId: $userId) { + ... on ErrorPayload { + error { + message + } + } + ...ToggleFeatureFlagMutation_notification @relay(mask: false) + } + } +` + +const ToggleFeatureFlagMutation: StandardMutation = ( + atmosphere, + variables, + {onError, onCompleted} +) => { + return commitMutation(atmosphere, { + mutation, + variables, + onCompleted, + onError + }) +} + +export default ToggleFeatureFlagMutation diff --git a/packages/client/subscriptions/NotificationSubscription.ts b/packages/client/subscriptions/NotificationSubscription.ts index 2fa341d18c7..a51a7ac4c78 100644 --- a/packages/client/subscriptions/NotificationSubscription.ts +++ b/packages/client/subscriptions/NotificationSubscription.ts @@ -167,6 +167,13 @@ const subscription = graphql` summary descriptionHTML } + + ToggleFeatureFlagSuccess { + featureFlag { + featureName + enabled + } + } } } ` diff --git a/packages/server/database/types/Organization.ts b/packages/server/database/types/Organization.ts index 8734b1c140f..a0afd5d3b4e 100644 --- a/packages/server/database/types/Organization.ts +++ b/packages/server/database/types/Organization.ts @@ -15,6 +15,7 @@ interface Input { updatedAt?: Date showConversionModal?: boolean payLaterClickCount?: number + useAI?: boolean } export default class Organization { @@ -37,6 +38,7 @@ export default class Organization { trialStartDate?: Date | null scheduledLockAt?: Date | null lockedAt?: Date | null + useAI: boolean updatedAt: Date constructor(input: Input) { const { @@ -50,7 +52,8 @@ export default class Organization { showConversionModal, payLaterClickCount, picture, - tier + tier, + useAI } = input this.id = id || generateUID() this.activeDomain = activeDomain @@ -63,5 +66,6 @@ export default class Organization { this.picture = picture this.showConversionModal = showConversionModal === null ? undefined : showConversionModal this.payLaterClickCount = payLaterClickCount || 0 + this.useAI = useAI ?? true } } diff --git a/packages/server/dataloader/customLoaderMakers.ts b/packages/server/dataloader/customLoaderMakers.ts index da562a0416c..365f317ce0e 100644 --- a/packages/server/dataloader/customLoaderMakers.ts +++ b/packages/server/dataloader/customLoaderMakers.ts @@ -27,7 +27,14 @@ import { selectTasks, selectTeams } from '../postgres/select' -import {Insight, MeetingSettings, OrganizationUser, Task, Team} from '../postgres/types' +import { + FeatureFlag, + Insight, + MeetingSettings, + OrganizationUser, + Task, + Team +} from '../postgres/types' import {AnyMeeting, MeetingTypeEnum} from '../postgres/types/Meeting' import {TeamMeetingTemplate} from '../postgres/types/pg' import {Logger} from '../utils/Logger' @@ -37,6 +44,7 @@ import NullableDataLoader from './NullableDataLoader' import RootDataLoader, {RegisterDependsOn} from './RootDataLoader' import normalizeArrayResults from './normalizeArrayResults' import normalizeResults from './normalizeResults' + export interface MeetingSettingsKey { teamId: string meetingType: MeetingTypeEnum @@ -852,6 +860,7 @@ export const latestInsightByTeamId = (parent: RootDataLoader) => { ) } +// whether a feature flag is enabled for a given owner (user, team, or org) export const featureFlagByOwnerId = (parent: RootDataLoader) => { return new DataLoader<{ownerId: string; featureName: string}, boolean, string>( async (keys) => { @@ -955,3 +964,64 @@ export const publicTemplatesByType = (parent: RootDataLoader) => { } ) } + +export const allFeatureFlags = (parent: RootDataLoader) => { + return new DataLoader<'Organization' | 'Team' | 'User' | 'all', FeatureFlag[], string>( + async (scopes) => { + const pg = getKysely() + return await Promise.all( + scopes.map(async (scope) => { + const flags = await pg + .selectFrom('FeatureFlag') + .selectAll() + .where('expiresAt', '>', new Date()) + .$if(scope !== 'all', (qb) => { + const validScope = scope as 'Organization' | 'Team' | 'User' + return qb.where('scope', '=', validScope) + }) + .orderBy('featureName') + .execute() + return flags.map((flag) => ({...flag, isEnabled: true})) + }) + ) + }, + { + ...parent.dataLoaderOptions, + cacheKeyFn: (scope) => scope + } + ) +} + +export const allFeatureFlagsByOwner = (parent: RootDataLoader) => { + return new DataLoader< + {ownerId: string; scope: 'Organization' | 'Team' | 'User'}, + FeatureFlag[], + string + >( + async (keys) => { + const flagsByOwnerId = await Promise.all( + keys.map(async ({ownerId, scope}) => { + const allFlags = await parent.get('allFeatureFlags').load(scope) + const flags = await Promise.all( + allFlags.map(async (flag) => { + const isEnabled = await parent + .get('featureFlagByOwnerId') + .load({ownerId, featureName: flag.featureName}) + return { + ...flag, + enabled: isEnabled + } + }) + ) + return flags + }) + ) + + return flagsByOwnerId + }, + { + ...parent.dataLoaderOptions, + cacheKeyFn: (key) => `${key.ownerId}:${key.scope}` + } + ) +} diff --git a/packages/server/graphql/mutations/helpers/canAccessAISummary.ts b/packages/server/graphql/mutations/helpers/canAccessAI.ts similarity index 63% rename from packages/server/graphql/mutations/helpers/canAccessAISummary.ts rename to packages/server/graphql/mutations/helpers/canAccessAI.ts index 3675b02804f..f262721ca26 100644 --- a/packages/server/graphql/mutations/helpers/canAccessAISummary.ts +++ b/packages/server/graphql/mutations/helpers/canAccessAI.ts @@ -3,19 +3,15 @@ import {Team} from '../../../postgres/types' import {DataLoaderWorker} from '../../graphql' import {getFeatureTier} from '../../types/helpers/getFeatureTier' -const canAccessAISummary = async ( +const canAccessAI = async ( team: Team, - userId: string, meetingType: 'standup' | 'retrospective', dataLoader: DataLoaderWorker ) => { const {qualAIMeetingsCount, orgId} = team - const [noAIOrgSummary, noAIUserSummary] = await Promise.all([ - dataLoader.get('featureFlagByOwnerId').load({ownerId: orgId, featureName: 'noAISummary'}), - dataLoader.get('featureFlagByOwnerId').load({ownerId: userId, featureName: 'noAISummary'}) - ]) + const org = await dataLoader.get('organizations').loadNonNull(orgId) - if (noAIOrgSummary || noAIUserSummary) return false + if (!org.useAI) return false if (meetingType === 'standup') { const hasStandupFlag = await dataLoader .get('featureFlagByOwnerId') @@ -27,4 +23,4 @@ const canAccessAISummary = async ( return qualAIMeetingsCount < Threshold.MAX_QUAL_AI_MEETINGS } -export default canAccessAISummary +export default canAccessAI diff --git a/packages/server/graphql/mutations/helpers/generateDiscussionPrompt.ts b/packages/server/graphql/mutations/helpers/generateDiscussionPrompt.ts index 586bd7bf350..7a6fe3f59ba 100644 --- a/packages/server/graphql/mutations/helpers/generateDiscussionPrompt.ts +++ b/packages/server/graphql/mutations/helpers/generateDiscussionPrompt.ts @@ -2,7 +2,7 @@ import getKysely from '../../../postgres/getKysely' import OpenAIServerManager from '../../../utils/OpenAIServerManager' import sendToSentry from '../../../utils/sendToSentry' import {DataLoaderWorker} from '../../graphql' -import canAccessAISummary from './canAccessAISummary' +import canAccessAI from './canAccessAI' const generateDiscussionPrompt = async ( meetingId: string, @@ -14,13 +14,8 @@ const generateDiscussionPrompt = async ( dataLoader.get('users').loadNonNull(facilitatorUserId), dataLoader.get('teams').loadNonNull(teamId) ]) - const isAISummaryAccessible = await canAccessAISummary( - team, - facilitator.id, - 'retrospective', - dataLoader - ) - if (!isAISummaryAccessible) return + const isAIAvailable = await canAccessAI(team, 'retrospective', dataLoader) + if (!isAIAvailable) return const [reflections, reflectionGroups] = await Promise.all([ dataLoader.get('retroReflectionsByMeetingId').load(meetingId), dataLoader.get('retroReflectionGroupsByMeetingId').load(meetingId) diff --git a/packages/server/graphql/mutations/helpers/generateDiscussionSummary.ts b/packages/server/graphql/mutations/helpers/generateDiscussionSummary.ts index a5871fb124b..34cf1535b07 100644 --- a/packages/server/graphql/mutations/helpers/generateDiscussionSummary.ts +++ b/packages/server/graphql/mutations/helpers/generateDiscussionSummary.ts @@ -5,25 +5,17 @@ import {RetrospectiveMeeting} from '../../../postgres/types/Meeting' import OpenAIServerManager from '../../../utils/OpenAIServerManager' import publish from '../../../utils/publish' import {DataLoaderWorker} from '../../graphql' -import canAccessAISummary from './canAccessAISummary' +import canAccessAI from './canAccessAI' const generateDiscussionSummary = async ( discussionId: string, meeting: RetrospectiveMeeting, dataLoader: DataLoaderWorker ) => { - const {id: meetingId, endedAt, facilitatorUserId, teamId} = meeting - const [facilitator, team] = await Promise.all([ - dataLoader.get('users').loadNonNull(facilitatorUserId!), - dataLoader.get('teams').loadNonNull(teamId) - ]) - const isAISummaryAccessible = await canAccessAISummary( - team, - facilitator.id, - 'retrospective', - dataLoader - ) - if (!isAISummaryAccessible) return + const {id: meetingId, endedAt, teamId} = meeting + const team = await dataLoader.get('teams').loadNonNull(teamId) + const isAIAvailable = await canAccessAI(team, 'retrospective', dataLoader) + if (!isAIAvailable) return const [comments, tasks] = await Promise.all([ dataLoader.get('commentsByDiscussionId').load(discussionId), dataLoader.get('tasksByDiscussionId').load(discussionId) diff --git a/packages/server/graphql/mutations/helpers/generateStandupMeetingSummary.ts b/packages/server/graphql/mutations/helpers/generateStandupMeetingSummary.ts index 997996f7b2c..bca491b14b7 100644 --- a/packages/server/graphql/mutations/helpers/generateStandupMeetingSummary.ts +++ b/packages/server/graphql/mutations/helpers/generateStandupMeetingSummary.ts @@ -2,24 +2,16 @@ import {getTeamPromptResponsesByMeetingId} from '../../../postgres/queries/getTe import {TeamPromptMeeting} from '../../../postgres/types/Meeting' import OpenAIServerManager from '../../../utils/OpenAIServerManager' import {DataLoaderWorker} from '../../graphql' -import canAccessAISummary from './canAccessAISummary' +import canAccessAI from './canAccessAI' const generateStandupMeetingSummary = async ( meeting: TeamPromptMeeting, dataLoader: DataLoaderWorker ) => { - const [facilitator, team] = await Promise.all([ - dataLoader.get('users').loadNonNull(meeting.facilitatorUserId!), - dataLoader.get('teams').loadNonNull(meeting.teamId) - ]) - const isAISummaryAccessible = await canAccessAISummary( - team, - facilitator.id, - 'standup', - dataLoader - ) + const team = await dataLoader.get('teams').loadNonNull(meeting.teamId) + const isAIAvailable = await canAccessAI(team, 'standup', dataLoader) + if (!isAIAvailable) return - if (!isAISummaryAccessible) return const responses = await getTeamPromptResponsesByMeetingId(meeting.id) const contentToSummarize = responses.map((response) => response.plaintextContent) diff --git a/packages/server/graphql/mutations/helpers/generateWholeMeetingSentimentScore.ts b/packages/server/graphql/mutations/helpers/generateWholeMeetingSentimentScore.ts index 4400e3998b4..264cd3e0445 100644 --- a/packages/server/graphql/mutations/helpers/generateWholeMeetingSentimentScore.ts +++ b/packages/server/graphql/mutations/helpers/generateWholeMeetingSentimentScore.ts @@ -1,18 +1,17 @@ import {DataLoaderWorker} from '../../graphql' +import canAccessAI from './canAccessAI' const generateWholeMeetingSentimentScore = async ( meetingId: string, - facilitatorUserId: string, dataLoader: DataLoaderWorker ) => { - const [facilitator, reflections] = await Promise.all([ - dataLoader.get('users').loadNonNull(facilitatorUserId), + const [meeting, reflections] = await Promise.all([ + dataLoader.get('newMeetings').loadNonNull(meetingId), dataLoader.get('retroReflectionsByMeetingId').load(meetingId) ]) - const hasNoAISummary = await dataLoader - .get('featureFlagByOwnerId') - .load({ownerId: facilitator.id, featureName: 'noAISummary'}) - if (hasNoAISummary || reflections.length === 0) return undefined + const team = await dataLoader.get('teams').loadNonNull(meeting.teamId) + const isAIAvailable = await canAccessAI(team, 'retrospective', dataLoader) + if (!isAIAvailable || reflections.length === 0) return undefined const reflectionsWithSentimentScores = reflections.filter( ({sentimentScore}) => sentimentScore !== undefined ) diff --git a/packages/server/graphql/mutations/helpers/generateWholeMeetingSummary.ts b/packages/server/graphql/mutations/helpers/generateWholeMeetingSummary.ts index b9d8ed4932e..d34245fa906 100644 --- a/packages/server/graphql/mutations/helpers/generateWholeMeetingSummary.ts +++ b/packages/server/graphql/mutations/helpers/generateWholeMeetingSummary.ts @@ -2,26 +2,17 @@ import {PARABOL_AI_USER_ID} from 'parabol-client/utils/constants' import OpenAIServerManager from '../../../utils/OpenAIServerManager' import {DataLoaderWorker} from '../../graphql' import isValid from '../../isValid' -import canAccessAISummary from './canAccessAISummary' +import canAccessAI from './canAccessAI' const generateWholeMeetingSummary = async ( discussionIds: string[], meetingId: string, teamId: string, - facilitatorUserId: string, dataLoader: DataLoaderWorker ) => { - const [facilitator, team] = await Promise.all([ - dataLoader.get('users').loadNonNull(facilitatorUserId), - dataLoader.get('teams').loadNonNull(teamId) - ]) - const isAISummaryAccessible = await canAccessAISummary( - team, - facilitator.id, - 'retrospective', - dataLoader - ) - if (!isAISummaryAccessible) return + const team = await dataLoader.get('teams').loadNonNull(teamId) + const isAIAvailable = await canAccessAI(team, 'retrospective', dataLoader) + if (!isAIAvailable) return const [commentsByDiscussions, tasksByDiscussions, reflections] = await Promise.all([ dataLoader.get('commentsByDiscussionId').loadMany(discussionIds), dataLoader.get('tasksByDiscussionId').loadMany(discussionIds), diff --git a/packages/server/graphql/mutations/helpers/safeEndRetrospective.ts b/packages/server/graphql/mutations/helpers/safeEndRetrospective.ts index 30e8316ce6f..dd968ceedd0 100644 --- a/packages/server/graphql/mutations/helpers/safeEndRetrospective.ts +++ b/packages/server/graphql/mutations/helpers/safeEndRetrospective.ts @@ -34,12 +34,12 @@ const getTranscription = async (recallBotId?: string | null) => { const summarizeRetroMeeting = async (meeting: RetrospectiveMeeting, context: InternalContext) => { const {dataLoader} = context - const {id: meetingId, phases, facilitatorUserId, teamId, recallBotId} = meeting + const {id: meetingId, phases, teamId, recallBotId} = meeting const pg = getKysely() const [reflectionGroups, reflections, sentimentScore] = await Promise.all([ dataLoader.get('retroReflectionGroupsByMeetingId').load(meetingId), dataLoader.get('retroReflectionsByMeetingId').load(meetingId), - generateWholeMeetingSentimentScore(meetingId, facilitatorUserId!, dataLoader) + generateWholeMeetingSentimentScore(meetingId, dataLoader) ]) const discussPhase = getPhase(phases, 'discuss') const {stages} = discussPhase @@ -47,7 +47,7 @@ const summarizeRetroMeeting = async (meeting: RetrospectiveMeeting, context: Int const reflectionGroupIds = reflectionGroups.map(({id}) => id) const [summary, transcription] = await Promise.all([ - generateWholeMeetingSummary(discussionIds, meetingId, teamId, facilitatorUserId!, dataLoader), + generateWholeMeetingSummary(discussionIds, meetingId, teamId, dataLoader), getTranscription(recallBotId) ]) const commentCounts = ( diff --git a/packages/server/graphql/public/mutations/toggleAIFeatures.ts b/packages/server/graphql/public/mutations/toggleAIFeatures.ts new file mode 100644 index 00000000000..74e1eefbcec --- /dev/null +++ b/packages/server/graphql/public/mutations/toggleAIFeatures.ts @@ -0,0 +1,40 @@ +import {sql} from 'kysely' +import {SubscriptionChannel} from '../../../../client/types/constEnums' +import getKysely from '../../../postgres/getKysely' +import {getUserId, isUserOrgAdmin} from '../../../utils/authorization' +import publish from '../../../utils/publish' +import standardError from '../../../utils/standardError' +import {MutationResolvers} from '../resolverTypes' + +const toggleAIFeatures: MutationResolvers['toggleAIFeatures'] = async ( + _source, + {orgId}, + {authToken, dataLoader, socketId: mutatorId} +) => { + const viewerId = getUserId(authToken) + const pg = getKysely() + + // VALIDATION + if (!(await isUserOrgAdmin(viewerId, orgId, dataLoader))) { + return standardError(new Error('Not organization admin')) + } + + // RESOLUTION + const operationId = dataLoader.share() + const subOptions = {mutatorId, operationId} + + await pg + .updateTable('Organization') + .set({useAI: sql`NOT "useAI"`}) + .where('id', '=', orgId) + .execute() + + const data = { + orgId + } + + publish(SubscriptionChannel.ORGANIZATION, orgId, 'ToggleAIFeaturesPayload', data, subOptions) + return data +} + +export default toggleAIFeatures diff --git a/packages/server/graphql/public/mutations/toggleFeatureFlag.ts b/packages/server/graphql/public/mutations/toggleFeatureFlag.ts new file mode 100644 index 00000000000..7e447f8d1b0 --- /dev/null +++ b/packages/server/graphql/public/mutations/toggleFeatureFlag.ts @@ -0,0 +1,108 @@ +import {SubscriptionChannel} from 'parabol-client/types/constEnums' +import toTeamMemberId from '../../../../client/utils/relay/toTeamMemberId' +import getKysely from '../../../postgres/getKysely' +import {getUserId, isUserOrgAdmin} from '../../../utils/authorization' +import publish from '../../../utils/publish' +import standardError from '../../../utils/standardError' +import {MutationResolvers} from '../resolverTypes' + +const toggleFeatureFlag: MutationResolvers['toggleFeatureFlag'] = async ( + _source, + {featureName, orgId, teamId, userId}, + {authToken, dataLoader} +) => { + const viewerId = getUserId(authToken) + const pg = getKysely() + + if (!orgId && !teamId && !userId) { + return standardError(new Error('Must provide one an orgId, teamId, or userId')) + } + + const ownerId = (orgId || teamId || userId) as string + + if (orgId && !(await isUserOrgAdmin(viewerId, orgId, dataLoader))) { + return standardError(new Error('Not organization admin')) + } + + if (teamId) { + const teamMemberId = toTeamMemberId(teamId, viewerId) + const teamMember = await dataLoader.get('teamMembers').load(teamMemberId) + if (!teamMember) { + return standardError(new Error('Not a member of the team')) + } + if (!teamMember.isLead) { + return standardError(new Error('Not team lead')) + } + } + + if (userId && userId !== viewerId) { + return standardError(new Error('Not the user')) + } + + const featureFlag = await pg + .selectFrom('FeatureFlag') + .selectAll() + .where('featureName', '=', featureName) + .where('expiresAt', '>', new Date()) + .executeTakeFirst() + + if (!featureFlag) { + return standardError(new Error('Feature flag not found or expired')) + } + + const scope = orgId ? 'Organization' : teamId ? 'Team' : 'User' + if (featureFlag.scope !== scope) { + return standardError( + new Error(`Feature flag is ${featureFlag.scope}-scoped, not ${scope}-scoped`) + ) + } + + const existingOwner = await pg + .selectFrom('FeatureFlagOwner') + .selectAll() + .where('featureFlagId', '=', featureFlag.id) + .where((eb) => + eb.or([ + eb('orgId', '=', orgId || null), + eb('teamId', '=', teamId || null), + eb('userId', '=', userId || null) + ]) + ) + .executeTakeFirst() + + const operationId = dataLoader.share() + const subOptions = {operationId} + const isEnabled = !!existingOwner + + if (isEnabled) { + await pg + .deleteFrom('FeatureFlagOwner') + .where('featureFlagId', '=', featureFlag.id) + .where((eb) => + eb.or([ + eb('orgId', '=', orgId || null), + eb('teamId', '=', teamId || null), + eb('userId', '=', userId || null) + ]) + ) + .execute() + } else { + await pg + .insertInto('FeatureFlagOwner') + .values({ + featureFlagId: featureFlag.id, + orgId: orgId || null, + teamId: teamId || null, + userId: userId || null + }) + .execute() + } + const data = { + featureFlagId: featureFlag.id, + enabled: !isEnabled + } + publish(SubscriptionChannel.NOTIFICATION, ownerId, 'ToggleFeatureFlagPayload', data, subOptions) + return data +} + +export default toggleFeatureFlag diff --git a/packages/server/graphql/public/typeDefs/FeatureFlag.graphql b/packages/server/graphql/public/typeDefs/FeatureFlag.graphql index 4582658e167..ec933cc343e 100644 --- a/packages/server/graphql/public/typeDefs/FeatureFlag.graphql +++ b/packages/server/graphql/public/typeDefs/FeatureFlag.graphql @@ -1,4 +1,9 @@ type FeatureFlag { + """ + The ID of the feature flag + """ + id: ID! + """ The name of the feature flag """ diff --git a/packages/server/graphql/public/typeDefs/Mutation.graphql b/packages/server/graphql/public/typeDefs/Mutation.graphql index a793fc21632..a679e5303e9 100644 --- a/packages/server/graphql/public/typeDefs/Mutation.graphql +++ b/packages/server/graphql/public/typeDefs/Mutation.graphql @@ -1135,6 +1135,38 @@ type Mutation { teamDrawerType: TeamDrawer ): ToggleTeamDrawerPayload! + """ + Toggle a feature flag for a specific owner (organization, team, or user) + """ + toggleFeatureFlag( + """ + The name of the feature flag to toggle + """ + featureName: String! + """ + The organization ID if toggling for an org + """ + orgId: ID + """ + The team ID if toggling for a team + """ + teamId: ID + """ + The user ID if toggling for a user + """ + userId: ID + ): ToggleFeatureFlagPayload! + + """ + Toggle whether the organization has access to ai features + """ + toggleAIFeatures( + """ + The id of the org being toggled + """ + orgId: ID! + ): ToggleAIFeaturesPayload! + """ Update how a parabol dimension maps to a GitHub label """ diff --git a/packages/server/graphql/public/typeDefs/NotificationSubscriptionPayload.graphql b/packages/server/graphql/public/typeDefs/NotificationSubscriptionPayload.graphql index b119801745f..56f7506527a 100644 --- a/packages/server/graphql/public/typeDefs/NotificationSubscriptionPayload.graphql +++ b/packages/server/graphql/public/typeDefs/NotificationSubscriptionPayload.graphql @@ -26,4 +26,5 @@ type NotificationSubscriptionPayload { UpdatedNotification: UpdatedNotification RemoveIntegrationSearchQuerySuccess: RemoveIntegrationSearchQuerySuccess PersistIntegrationSearchQuerySuccess: PersistIntegrationSearchQuerySuccess + ToggleFeatureFlagSuccess: ToggleFeatureFlagSuccess } diff --git a/packages/server/graphql/public/typeDefs/Organization.graphql b/packages/server/graphql/public/typeDefs/Organization.graphql index 85fe5a69614..e588b1652a1 100644 --- a/packages/server/graphql/public/typeDefs/Organization.graphql +++ b/packages/server/graphql/public/typeDefs/Organization.graphql @@ -163,6 +163,12 @@ type Organization { """ picture: URL tier: TierEnum! + + """ + Whether the org has access to AI features + """ + useAI: Boolean! + billingTier: TierEnum! """ @@ -195,4 +201,9 @@ type Organization { Custom integration providers with organization scope """ integrationProviders: OrgIntegrationProviders! + + """ + The feature flags the org has enabled + """ + orgFeatureFlags: [OwnedFeatureFlag!]! } diff --git a/packages/server/graphql/public/typeDefs/OrganizationSubscriptionPayload.graphql b/packages/server/graphql/public/typeDefs/OrganizationSubscriptionPayload.graphql index 259e614cf52..bd689953ce5 100644 --- a/packages/server/graphql/public/typeDefs/OrganizationSubscriptionPayload.graphql +++ b/packages/server/graphql/public/typeDefs/OrganizationSubscriptionPayload.graphql @@ -9,6 +9,7 @@ type OrganizationSubscriptionPayload { PayLaterPayload: PayLaterPayload RemoveIntegrationProviderSuccess: RemoveIntegrationProviderSuccess RemoveOrgUserPayload: RemoveOrgUserPayload + ToggleAIFeaturesSuccess: ToggleAIFeaturesSuccess SetOrgUserRoleSuccess: SetOrgUserRoleSuccess UpdateCreditCardPayload: UpdateCreditCardPayload UpdateIntegrationProviderSuccess: UpdateIntegrationProviderSuccess diff --git a/packages/server/graphql/public/typeDefs/OwnedFeatureFlag.graphql b/packages/server/graphql/public/typeDefs/OwnedFeatureFlag.graphql new file mode 100644 index 00000000000..7d4828f7fef --- /dev/null +++ b/packages/server/graphql/public/typeDefs/OwnedFeatureFlag.graphql @@ -0,0 +1,31 @@ +type OwnedFeatureFlag { + """ + The ID of the feature flag + """ + id: ID! + + """ + The name of the feature flag + """ + featureName: String! + + """ + Description of the feature flag + """ + description: String + + """ + Expiration date of the feature flag + """ + expiresAt: DateTime! + + """ + The scope of the feature flag + """ + scope: FeatureFlagScope! + + """ + Whether the flag is enabled for an owner or not + """ + enabled: Boolean +} diff --git a/packages/server/graphql/public/typeDefs/ToggleAIFeaturesPayload.graphql b/packages/server/graphql/public/typeDefs/ToggleAIFeaturesPayload.graphql new file mode 100644 index 00000000000..49f5a93d260 --- /dev/null +++ b/packages/server/graphql/public/typeDefs/ToggleAIFeaturesPayload.graphql @@ -0,0 +1,4 @@ +""" +Return value for toggleAIFeatures, which could be an error +""" +union ToggleAIFeaturesPayload = ErrorPayload | ToggleAIFeaturesSuccess diff --git a/packages/server/graphql/public/typeDefs/ToggleAIFeaturesSuccess.graphql b/packages/server/graphql/public/typeDefs/ToggleAIFeaturesSuccess.graphql new file mode 100644 index 00000000000..7b1feca495a --- /dev/null +++ b/packages/server/graphql/public/typeDefs/ToggleAIFeaturesSuccess.graphql @@ -0,0 +1,3 @@ +type ToggleAIFeaturesSuccess { + organization: Organization! +} diff --git a/packages/server/graphql/public/typeDefs/ToggleFeatureFlagPayload.graphql b/packages/server/graphql/public/typeDefs/ToggleFeatureFlagPayload.graphql new file mode 100644 index 00000000000..f5ae2e9e60b --- /dev/null +++ b/packages/server/graphql/public/typeDefs/ToggleFeatureFlagPayload.graphql @@ -0,0 +1 @@ +union ToggleFeatureFlagPayload = ErrorPayload | ToggleFeatureFlagSuccess diff --git a/packages/server/graphql/public/typeDefs/ToggleFeatureFlagSuccess.graphql b/packages/server/graphql/public/typeDefs/ToggleFeatureFlagSuccess.graphql new file mode 100644 index 00000000000..03d63361bf2 --- /dev/null +++ b/packages/server/graphql/public/typeDefs/ToggleFeatureFlagSuccess.graphql @@ -0,0 +1,6 @@ +type ToggleFeatureFlagSuccess { + """ + The feature flag that was toggled + """ + featureFlag: OwnedFeatureFlag! +} diff --git a/packages/server/graphql/public/types/Organization.ts b/packages/server/graphql/public/types/Organization.ts index b705d1c355d..e44231e107f 100644 --- a/packages/server/graphql/public/types/Organization.ts +++ b/packages/server/graphql/public/types/Organization.ts @@ -135,7 +135,10 @@ const Organization: OrganizationResolvers = { organizationUser.role === 'BILLING_LEADER' || organizationUser.role === 'ORG_ADMIN' ) }, - integrationProviders: ({id: orgId}) => ({orgId}) + integrationProviders: ({id: orgId}) => ({orgId}), + orgFeatureFlags: async ({id: orgId}, _args, {dataLoader}) => { + return dataLoader.get('allFeatureFlagsByOwner').load({ownerId: orgId, scope: 'Organization'}) + } } export default Organization diff --git a/packages/server/graphql/public/types/ToggleAIFeaturesSuccess.ts b/packages/server/graphql/public/types/ToggleAIFeaturesSuccess.ts new file mode 100644 index 00000000000..1d07aed8aa1 --- /dev/null +++ b/packages/server/graphql/public/types/ToggleAIFeaturesSuccess.ts @@ -0,0 +1,13 @@ +import {ToggleAiFeaturesSuccessResolvers} from '../resolverTypes' + +export type ToggleAIFeaturesSuccessSource = { + orgId: string +} + +const ToggleAIFeaturesSuccess: ToggleAiFeaturesSuccessResolvers = { + organization: async ({orgId}, _args, {dataLoader}) => { + return await dataLoader.get('organizations').loadNonNull(orgId) + } +} + +export default ToggleAIFeaturesSuccess diff --git a/packages/server/graphql/public/types/ToggleFeatureFlagSuccess.ts b/packages/server/graphql/public/types/ToggleFeatureFlagSuccess.ts new file mode 100644 index 00000000000..34a678b711d --- /dev/null +++ b/packages/server/graphql/public/types/ToggleFeatureFlagSuccess.ts @@ -0,0 +1,18 @@ +import {ToggleFeatureFlagSuccessResolvers} from '../resolverTypes' + +export type ToggleFeatureFlagSuccessSource = { + featureFlagId: string + enabled: boolean +} + +const ToggleFeatureFlagSuccess: ToggleFeatureFlagSuccessResolvers = { + featureFlag: async ({featureFlagId, enabled}, _, {dataLoader}) => { + const flag = await dataLoader.get('featureFlags').loadNonNull(featureFlagId) + return { + ...flag, + enabled + } + } +} + +export default ToggleFeatureFlagSuccess diff --git a/packages/server/postgres/migrations/2024-10-31T14:42:16.120Z_add-use-ai-to-orgs.ts b/packages/server/postgres/migrations/2024-10-31T14:42:16.120Z_add-use-ai-to-orgs.ts new file mode 100644 index 00000000000..2801efbfe03 --- /dev/null +++ b/packages/server/postgres/migrations/2024-10-31T14:42:16.120Z_add-use-ai-to-orgs.ts @@ -0,0 +1,59 @@ +import {Kysely, sql} from 'kysely' + +export async function up(db: Kysely): Promise { + await db.schema + .alterTable('Organization') + .addColumn('useAI', 'boolean', (col) => col.notNull().defaultTo(true)) + .execute() + + await db + .updateTable('Organization') + .set({useAI: false}) + .where( + 'id', + 'in', + db + .selectFrom('FeatureFlagOwner') + .innerJoin('FeatureFlag', 'FeatureFlag.id', 'FeatureFlagOwner.featureFlagId') + .where('FeatureFlag.featureName', '=', 'noAISummary') + .select('FeatureFlagOwner.orgId') + ) + .execute() + + await db + .deleteFrom('FeatureFlagOwner') + .where( + 'featureFlagId', + 'in', + db.selectFrom('FeatureFlag').select('id').where('featureName', '=', 'noAISummary') + ) + .execute() + + await db.deleteFrom('FeatureFlag').where('featureName', '=', 'noAISummary').execute() +} + +export async function down(db: Kysely): Promise { + const [flagId] = await db + .insertInto('FeatureFlag') + .values({ + featureName: 'noAISummary', + description: 'Disable AI features for this organization', + expiresAt: new Date('2074-01-31T00:00:00.000Z'), + scope: 'Organization' + }) + .returning('id') + .execute() + + await db + .insertInto('FeatureFlagOwner') + .columns(['featureFlagId', 'orgId']) + .expression( + db + .selectFrom('Organization') + .select([sql`${flagId.id}::uuid`.as('featureFlagId'), 'id as orgId']) + .where('useAI', '=', false) + ) + .execute() + + await db.schema.alterTable('Organization').dropColumn('useAI').execute() +} diff --git a/packages/server/postgres/select.ts b/packages/server/postgres/select.ts index efae11cccfb..6223dbaecb9 100644 --- a/packages/server/postgres/select.ts +++ b/packages/server/postgres/select.ts @@ -138,6 +138,7 @@ export const selectOrganizations = () => 'trialStartDate', 'scheduledLockAt', 'lockedAt', + 'useAI', 'updatedAt' ]) .select(({fn}) => [fn('to_json', ['creditCard']).as('creditCard')]) diff --git a/packages/server/postgres/types/index.d.ts b/packages/server/postgres/types/index.d.ts index a91a82d3101..af0ff428cf4 100644 --- a/packages/server/postgres/types/index.d.ts +++ b/packages/server/postgres/types/index.d.ts @@ -21,6 +21,7 @@ import { } from '../select' import { Discussion as DiscussionPG, + FeatureFlag as FeatureFlagPG, Insight as InsightPG, OrganizationUser as OrganizationUserPG, TaskEstimate as TaskEstimatePG, @@ -80,6 +81,7 @@ export type TemplateScaleRef = ExtractTypeFromQueryBuilderSelect export type PokerMeetingSettings = MeetingSettings & {meetingType: 'poker'} export type RetrospectiveMeetingSettings = MeetingSettings & {meetingType: 'retrospective'} +export type FeatureFlag = Selectable export type AgendaItem = ExtractTypeFromQueryBuilderSelect