diff --git a/codegen.json b/codegen.json index 8f7b2e9aa11..ffd62784cff 100644 --- a/codegen.json +++ b/codegen.json @@ -14,6 +14,7 @@ "ChangeEmailDomainSuccess": "./types/ChangeEmailDomainSuccess#ChangeEmailDomainSuccessSource", "Company": "./queries/company#CompanySource", "DraftEnterpriseInvoicePayload": "./types/DraftEnterpriseInvoicePayload#DraftEnterpriseInvoicePayloadSource", + "EndTrialSuccess": "./types/EndTrialSuccess#EndTrialSuccessSource", "File": "../public/types/File#TFile", "FlagConversionModalPayload": "./types/FlagConversionModalPayload#FlagConversionModalPayloadSource", "FlagOverLimitPayload": "./types/FlagOverLimitPayload#FlagOverLimitPayloadSource", @@ -27,6 +28,7 @@ "SAML": "./types/SAML#SAMLSource", "SetIsFreeMeetingTemplateSuccess": "./types/SetIsFreeMeetingTemplateSuccess#SetIsFreeMeetingTemplateSuccessSource", "SignupsPayload": "./types/SignupsPayload#SignupsPayloadSource", + "StartTrialSuccess": "./types/StartTrialSuccess#StartTrialSuccessSource", "StripeFailPaymentPayload": "./mutations/stripeFailPayment#StripeFailPaymentPayloadSource", "Team": "../../postgres/queries/getTeamsByIds#Team", "UpdateOrgFeatureFlagSuccess": "./types/UpdateOrgFeatureFlagSuccess#UpdateOrgFeatureFlagSuccessSource", @@ -93,8 +95,8 @@ "RequestToJoinDomainSuccess": "./types/RequestToJoinDomainSuccess#RequestToJoinDomainSuccessSource", "ResetReflectionGroupsSuccess": "./types/ResetReflectionGroupsSuccess#ResetReflectionGroupsSuccessSource", "RetrospectiveMeeting": "../../database/types/MeetingRetrospective#default", - "SAML": "./types/SAML#SAMLSource", "RetrospectiveMeetingSettings": "../../database/types/MeetingSettingsRetrospective#default", + "SAML": "./types/SAML#SAMLSource", "SetMeetingSettingsPayload": "../types/SetMeetingSettingsPayload#SetMeetingSettingsPayloadSource", "SetNotificationStatusPayload": "./types/SetNotificationStatusPayload#SetNotificationStatusPayloadSource", "SetOrgUserRoleSuccess": "./types/SetOrgUserRoleSuccess#SetOrgUserRoleSuccessSource", @@ -114,8 +116,8 @@ "TemplateDimension": "../../database/types/TemplateDimension#default", "TimelineEventTeamPromptComplete": "./types/TimelineEventTeamPromptComplete#TimelineEventTeamPromptCompleteSource", "ToggleSummaryEmailSuccess": "./types/ToggleSummaryEmailSuccess#ToggleSummaryEmailSuccessSource", - "UpdateAutoJoinSuccess": "./types/UpdateAutoJoinSuccess#UpdateAutoJoinSuccessSource", "TopRetroTemplate": "./types/TopRetroTemplate#TopRetroTemplateSource", + "UpdateAutoJoinSuccess": "./types/UpdateAutoJoinSuccess#UpdateAutoJoinSuccessSource", "UpdateCreditCardSuccess": "./types/UpdateCreditCardSuccess#UpdateCreditCardSuccessSource", "UpdateDimensionFieldSuccess": "./types/UpdateDimensionFieldSuccess#UpdateDimensionFieldSuccessSource", "UpdateFeatureFlagPayload": "./types/UpdateFeatureFlagPayload#UpdateFeatureFlagPayloadSource", diff --git a/packages/client/components/ActivityLibrary/ActivityDetails/ActivityDetails.tsx b/packages/client/components/ActivityLibrary/ActivityDetails/ActivityDetails.tsx index aafbb30bda8..7aa7c301799 100644 --- a/packages/client/components/ActivityLibrary/ActivityDetails/ActivityDetails.tsx +++ b/packages/client/components/ActivityLibrary/ActivityDetails/ActivityDetails.tsx @@ -38,7 +38,6 @@ export const query = graphql` viewer { ...ActivityDetailsSidebar_viewer preferredTeamId - tier activity(activityId: $activityId) { ...ActivityDetails_template @relay(mask: false) } diff --git a/packages/client/components/BillingLeaderActionMenu.tsx b/packages/client/components/BillingLeaderActionMenu.tsx index 73806bf918c..d8ee60cf36a 100644 --- a/packages/client/components/BillingLeaderActionMenu.tsx +++ b/packages/client/components/BillingLeaderActionMenu.tsx @@ -36,7 +36,7 @@ const BillingLeaderActionMenu = (props: Props) => { graphql` fragment BillingLeaderActionMenu_organization on Organization { id - tier + billingTier } `, organizationRef @@ -54,7 +54,7 @@ const BillingLeaderActionMenu = (props: Props) => { organizationUserRef ) const atmosphere = useAtmosphere() - const {id: orgId, tier} = organization + const {id: orgId, billingTier} = organization const {viewerId} = atmosphere const {newUserUntil, role, user} = organizationUser const isBillingLeader = role === 'BILLING_LEADER' @@ -84,7 +84,7 @@ const BillingLeaderActionMenu = (props: Props) => { {viewerId !== userId && ( new Date() + billingTier === 'team' && new Date(newUserUntil) > new Date() ? 'Refund and Remove' : 'Remove from Organization' } diff --git a/packages/client/components/NewTeamOrgDropdown.tsx b/packages/client/components/NewTeamOrgDropdown.tsx index bc8e9f74bcc..9f5fb0935d9 100644 --- a/packages/client/components/NewTeamOrgDropdown.tsx +++ b/packages/client/components/NewTeamOrgDropdown.tsx @@ -24,6 +24,7 @@ const NewTeamOrgDropdown = (props: Props) => { id name tier + billingTier } `, organizationsRef @@ -36,14 +37,14 @@ const NewTeamOrgDropdown = (props: Props) => { > Select Organization: {organizations.map((anOrg) => { - const {id, tier, name} = anOrg + const {id, tier, billingTier, name} = anOrg return ( {name} - {tier !== 'starter' && } + {tier !== 'starter' && } } onClick={() => { diff --git a/packages/client/components/StandardHub/StandardHub.tsx b/packages/client/components/StandardHub/StandardHub.tsx index 959ed914a46..5180979e4d0 100644 --- a/packages/client/components/StandardHub/StandardHub.tsx +++ b/packages/client/components/StandardHub/StandardHub.tsx @@ -88,7 +88,8 @@ const DEFAULT_VIEWER = { picture: '', preferredName: '', email: '', - tier: 'starter' + tier: 'starter', + billingTier: 'starter' } as const const StandardHub = (props: Props) => { @@ -100,11 +101,12 @@ const StandardHub = (props: Props) => { picture preferredName tier + billingTier } `, viewerRef ) - const {email, picture, preferredName, tier} = viewer || DEFAULT_VIEWER + const {email, picture, preferredName, tier, billingTier} = viewer || DEFAULT_VIEWER const userAvatar = picture || defaultUserAvatar const {history} = useRouter() const handleUpgradeClick = () => { @@ -124,13 +126,13 @@ const StandardHub = (props: Props) => { {email} - {tier === 'starter' ? ( + {billingTier === 'starter' ? ( {'Upgrade'} ) : ( - + )} ) diff --git a/packages/client/components/StandardHubUserMenu.tsx b/packages/client/components/StandardHubUserMenu.tsx index b093d162e6a..86a84d358f3 100644 --- a/packages/client/components/StandardHubUserMenu.tsx +++ b/packages/client/components/StandardHubUserMenu.tsx @@ -48,7 +48,7 @@ const StandardHubUserMenu = (props: Props) => { } organizations { id - tier + billingTier } } `, @@ -56,7 +56,7 @@ const StandardHubUserMenu = (props: Props) => { ) const {email, featureFlags, organizations} = viewer const {insights} = featureFlags - const ownedFreeOrgs = organizations.filter((org) => org.tier === 'starter') + const ownedFreeOrgs = organizations.filter((org) => org.billingTier === 'starter') const showUpgradeCTA = ownedFreeOrgs.length > 0 const routeSuffix = ownedFreeOrgs.length === 1 ? `/${ownedFreeOrgs[0]!.id}` : '' diff --git a/packages/client/components/Tag/TierTag.tsx b/packages/client/components/Tag/TierTag.tsx index cab2090b302..53332df10e7 100644 --- a/packages/client/components/Tag/TierTag.tsx +++ b/packages/client/components/Tag/TierTag.tsx @@ -8,6 +8,7 @@ import BaseTag from './BaseTag' interface Props { className?: string tier: TierEnum | null + billingTier: TierEnum | null } const StarterTag = styled(BaseTag)({ @@ -26,7 +27,8 @@ const EnterpriseTag = styled(BaseTag)({ }) const TierTag = (props: Props) => { - const {className, tier} = props + const {className, tier, billingTier} = props + if (tier !== billingTier) return {'Free Trial'} if (tier === 'starter') return {TierLabel.STARTER} if (tier === 'team') return {TierLabel.TEAM} if (tier === 'enterprise') diff --git a/packages/client/components/ThreadedCommentBase.tsx b/packages/client/components/ThreadedCommentBase.tsx index df29384c455..0b46cfb7682 100644 --- a/packages/client/components/ThreadedCommentBase.tsx +++ b/packages/client/components/ThreadedCommentBase.tsx @@ -66,7 +66,7 @@ const ThreadedCommentBase = (props: Props) => { graphql` fragment ThreadedCommentBase_viewer on User { ...ThreadedItemReply_viewer - tier + billingTier } `, viewerRef @@ -140,7 +140,7 @@ const ThreadedCommentBase = (props: Props) => { if (createdByUserNullable?.id === PARABOL_AI_USER_ID) { SendClientSideEvent(atmosphere, 'AI Summary Viewed', { source: 'Discussion', - tier: viewer.tier, + tier: viewer.billingTier, meetingId, discussionTopicId }) diff --git a/packages/client/hooks/useUsageSnackNag.ts b/packages/client/hooks/useUsageSnackNag.ts index 618b9b6a32b..f8427654ab3 100644 --- a/packages/client/hooks/useUsageSnackNag.ts +++ b/packages/client/hooks/useUsageSnackNag.ts @@ -17,10 +17,10 @@ const getIsNaggingPath = (history: RouterProps['history']) => { return !(pathname.includes('/usage') || pathname.includes('/meet/')) } -const shouldNag = (tier: TierEnum, suggestedTier: TierEnum | null) => { +const shouldNag = (billingTier: TierEnum, suggestedTier: TierEnum | null) => { if (!suggestedTier) return false - const suggestPro = suggestedTier === 'team' && tier === 'starter' - const suggestEnterprise = suggestedTier === 'enterprise' && tier !== 'enterprise' + const suggestPro = suggestedTier === 'team' && billingTier === 'starter' + const suggestEnterprise = suggestedTier === 'enterprise' && billingTier !== 'enterprise' return suggestPro || suggestEnterprise } diff --git a/packages/client/modules/demo/initDB.ts b/packages/client/modules/demo/initDB.ts index fe44066f616..2475b9db54f 100644 --- a/packages/client/modules/demo/initDB.ts +++ b/packages/client/modules/demo/initDB.ts @@ -293,6 +293,7 @@ const initDemoOrg = () => { id: demoOrgId, name: 'Demo Organization', tier: 'team', + billingTier: 'team', orgUserCount: { activeUserCount: 5, inactiveUserCount: 0 @@ -327,6 +328,7 @@ const initDemoTeam = ( teamName: demoTeamName, orgId: demoOrgId, tier: 'team', + billingTier: 'team', teamId: demoTeamId, organization, meetingSettings: initMeetingSettings(), diff --git a/packages/client/modules/email/components/SummaryEmail/MeetingSummaryEmail/WholeMeetingSummaryResult.tsx b/packages/client/modules/email/components/SummaryEmail/MeetingSummaryEmail/WholeMeetingSummaryResult.tsx index 1f8e1d796c7..76aba9a8828 100644 --- a/packages/client/modules/email/components/SummaryEmail/MeetingSummaryEmail/WholeMeetingSummaryResult.tsx +++ b/packages/client/modules/email/components/SummaryEmail/MeetingSummaryEmail/WholeMeetingSummaryResult.tsx @@ -49,6 +49,7 @@ const WholeMeetingSummaryResult = (props: Props) => { summary team { tier + billingTier } } `, @@ -60,7 +61,7 @@ const WholeMeetingSummaryResult = (props: Props) => { useEffect(() => { SendClientSideEvent(atmosphere, 'AI Summary Viewed', { source: 'Meeting Summary', - tier: meeting.team.tier, + tier: meeting.team.billingTier, meetingId: meeting.id }) }, []) diff --git a/packages/client/modules/invoice/components/InvoiceHeader/InvoiceHeader.tsx b/packages/client/modules/invoice/components/InvoiceHeader/InvoiceHeader.tsx index a11b4391539..0e8611f1263 100644 --- a/packages/client/modules/invoice/components/InvoiceHeader/InvoiceHeader.tsx +++ b/packages/client/modules/invoice/components/InvoiceHeader/InvoiceHeader.tsx @@ -84,7 +84,7 @@ const InvoiceHeader = (props: Props) => { {orgName} - {tier !== 'starter' && } + {tier !== 'starter' && } {billingLeaderEmails.map((email) => ( {email} ))} diff --git a/packages/client/modules/team/components/NewTeamOrgPicker.tsx b/packages/client/modules/team/components/NewTeamOrgPicker.tsx index f2ac8b57948..f0e942e07da 100644 --- a/packages/client/modules/team/components/NewTeamOrgPicker.tsx +++ b/packages/client/modules/team/components/NewTeamOrgPicker.tsx @@ -50,6 +50,7 @@ const NewTeamOrgPicker = (props: Props) => { id name tier + billingTier } `, organizationsRef @@ -85,7 +86,9 @@ const NewTeamOrgPicker = (props: Props) => { defaultText={ {defaultText} - {org && org.tier !== 'starter' && } + {org && org.tier !== 'starter' && ( + + )} } /> diff --git a/packages/client/modules/teamDashboard/components/TeamSettings/TeamSettings.tsx b/packages/client/modules/teamDashboard/components/TeamSettings/TeamSettings.tsx index 75741332b21..6d0463d2fc5 100644 --- a/packages/client/modules/teamDashboard/components/TeamSettings/TeamSettings.tsx +++ b/packages/client/modules/teamDashboard/components/TeamSettings/TeamSettings.tsx @@ -47,6 +47,7 @@ const query = graphql` id name tier + billingTier orgId teamMembers(sortBy: "preferredName") { teamMemberId: id @@ -67,7 +68,7 @@ const TeamSettings = (props: Props) => { const {viewer} = data const {history} = useRouter() const {team} = viewer - const {name: teamName, orgId, teamMembers, tier} = team! + const {name: teamName, orgId, teamMembers, tier, billingTier} = team! useDocumentTitle(`Team Settings | ${teamName}`, 'Team Settings') const viewerTeamMember = teamMembers.find((m) => m.isSelf) // if kicked out, the component might reload before the redirect occurs @@ -78,10 +79,14 @@ const TeamSettings = (props: Props) => { return ( - {tier === 'starter' && ( + {billingTier === 'starter' && ( -
{'This team is currently on a starter plan.'}
+
+ {tier !== 'starter' + ? `This team is currently on a free trial for the ${TierLabel.TEAM} plan.` + : 'This team is currently on a starter plan.'} +
history.push(`/me/organizations/${orgId}`)}> {`Upgrade Team to ${TierLabel.TEAM}`} @@ -98,8 +103,8 @@ const TeamSettings = (props: Props) => {
- This team is currently on a {tier} plan. Only Team - Leads can Upgrade plans and Delete a team.
+ This team is currently on a {billingTier} plan. Only + Team Leads can Upgrade plans and Delete a team.
The Team Lead for {teamName} is{' '} {contact.preferredName} diff --git a/packages/client/modules/userDashboard/components/OrgBilling/OrgBilling.tsx b/packages/client/modules/userDashboard/components/OrgBilling/OrgBilling.tsx index 646510bc9d8..94d57b149df 100644 --- a/packages/client/modules/userDashboard/components/OrgBilling/OrgBilling.tsx +++ b/packages/client/modules/userDashboard/components/OrgBilling/OrgBilling.tsx @@ -40,17 +40,17 @@ const OrgBilling = (props: Props) => { ...OrgBillingUpgrade_organization ...OrgBillingDangerZone_organization id - tier + billingTier } `, organizationRef ) - const {tier} = organization + const {billingTier} = organization return (
- {tier === 'team' && ( + {billingTier === 'team' && ( <> diff --git a/packages/client/modules/userDashboard/components/OrgBilling/OrgBillingDangerZone.tsx b/packages/client/modules/userDashboard/components/OrgBilling/OrgBillingDangerZone.tsx index 024b79a33c3..998048c07d5 100644 --- a/packages/client/modules/userDashboard/components/OrgBilling/OrgBillingDangerZone.tsx +++ b/packages/client/modules/userDashboard/components/OrgBilling/OrgBillingDangerZone.tsx @@ -59,14 +59,14 @@ const OrgBillingDangerZone = (props: Props) => { fragment OrgBillingDangerZone_organization on Organization { ...ArchiveOrganization_organization isBillingLeader - tier + billingTier } `, organizationRef ) - const {isBillingLeader, tier} = organization + const {isBillingLeader, billingTier} = organization if (!isBillingLeader) return null - const isStarter = tier === 'starter' + const isStarter = billingTier === 'starter' return ( diff --git a/packages/client/modules/userDashboard/components/OrgBilling/OrgBillingUpgrade.tsx b/packages/client/modules/userDashboard/components/OrgBilling/OrgBillingUpgrade.tsx index c36bf5c2c03..c2d5c3fe876 100644 --- a/packages/client/modules/userDashboard/components/OrgBilling/OrgBillingUpgrade.tsx +++ b/packages/client/modules/userDashboard/components/OrgBilling/OrgBillingUpgrade.tsx @@ -47,7 +47,7 @@ const OrgBillingUpgrade = (props: Props) => { graphql` fragment OrgBillingUpgrade_organization on Organization { id - tier + billingTier orgUserCount { activeUserCount } @@ -55,7 +55,7 @@ const OrgBillingUpgrade = (props: Props) => { `, organizationRef ) - const {id: orgId, tier, orgUserCount} = organization + const {id: orgId, billingTier, orgUserCount} = organization const {activeUserCount} = orgUserCount const {togglePortal, closePortal, modalPortal} = useModal() const onUpgrade = () => invoiceListRefetch?.({orgId, first: 3}) @@ -70,7 +70,7 @@ const OrgBillingUpgrade = (props: Props) => { activeUserCount={activeUserCount} /> )} - {tier === 'starter' && ( + {billingTier === 'starter' && ( Upgrade diff --git a/packages/client/modules/userDashboard/components/OrgBilling/OrgDetails.tsx b/packages/client/modules/userDashboard/components/OrgBilling/OrgDetails.tsx index 842b8ff3be4..6fea1869a96 100644 --- a/packages/client/modules/userDashboard/components/OrgBilling/OrgDetails.tsx +++ b/packages/client/modules/userDashboard/components/OrgBilling/OrgDetails.tsx @@ -26,13 +26,22 @@ const OrgDetails = (props: Props) => { isBillingLeader createdAt picture + billingTier tier name } `, organizationRef ) - const {orgId, createdAt, isBillingLeader, picture: orgAvatar, name, tier} = organization + const { + orgId, + createdAt, + isBillingLeader, + picture: orgAvatar, + name, + billingTier, + tier + } = organization const pictureOrDefault = orgAvatar ?? defaultOrgAvatar const orgName = name ?? 'Unknown' const {togglePortal, modalPortal} = useModal() @@ -56,7 +65,7 @@ const OrgDetails = (props: Props) => { ) : (
{orgName}
)} - +
diff --git a/packages/client/modules/userDashboard/components/OrgBilling/OrgPlanDrawer.tsx b/packages/client/modules/userDashboard/components/OrgBilling/OrgPlanDrawer.tsx index 19b2e9ef332..7b8ef509728 100644 --- a/packages/client/modules/userDashboard/components/OrgBilling/OrgPlanDrawer.tsx +++ b/packages/client/modules/userDashboard/components/OrgBilling/OrgPlanDrawer.tsx @@ -78,13 +78,13 @@ const OrgPlanDrawer = (props: Props) => { fragment OrgPlanDrawer_organization on Organization { id showDrawer - tier + billingTier showConfetti } `, organizationRef ) - const {id: orgId, tier, showDrawer, showConfetti} = organization + const {id: orgId, billingTier, showDrawer, showConfetti} = organization const atmosphere = useAtmosphere() const isDesktop = useBreakpoint(Breakpoint.ORG_DRAWER) @@ -113,7 +113,7 @@ const OrgPlanDrawer = (props: Props) => { - +
diff --git a/packages/client/modules/userDashboard/components/OrgBilling/OrgPlans.tsx b/packages/client/modules/userDashboard/components/OrgBilling/OrgPlans.tsx index 191df989c9d..e355a1e8381 100644 --- a/packages/client/modules/userDashboard/components/OrgBilling/OrgPlans.tsx +++ b/packages/client/modules/userDashboard/components/OrgBilling/OrgPlans.tsx @@ -74,7 +74,7 @@ const OrgPlans = (props: Props) => { ...DowngradeModal_organization ...LimitExceededWarning_organization id - tier + billingTier scheduledLockAt lockedAt } @@ -83,7 +83,7 @@ const OrgPlans = (props: Props) => { ) const {closePortal: closeModal, openPortal, modalPortal} = useModal() const atmosphere = useAtmosphere() - const {id: orgId, scheduledLockAt, lockedAt, tier} = organization + const {id: orgId, scheduledLockAt, lockedAt, billingTier} = organization const showNudge = scheduledLockAt || lockedAt const isTablet = useBreakpoint(Breakpoint.FUZZY_TABLET) @@ -97,24 +97,24 @@ const OrgPlans = (props: Props) => { 'Retrospectives, Sprint Poker, Standups, Check-Ins', 'Unlimited team members' ], - buttonStyle: getButtonStyle(tier, 'starter'), - buttonLabel: getButtonLabel(tier, 'starter'), - isActive: !hasSelectedTeamPlan && tier === 'starter' + buttonStyle: getButtonStyle(billingTier, 'starter'), + buttonLabel: getButtonLabel(billingTier, 'starter'), + isActive: !hasSelectedTeamPlan && billingTier === 'starter' }, { tier: 'team', details: ['Everything in Starter', ...TeamBenefits], - buttonStyle: getButtonStyle(tier, 'team'), - buttonLabel: getButtonLabel(tier, 'team'), - isActive: hasSelectedTeamPlan || tier === 'team' + buttonStyle: getButtonStyle(billingTier, 'team'), + buttonLabel: getButtonLabel(billingTier, 'team'), + isActive: hasSelectedTeamPlan || billingTier === 'team' }, { tier: 'enterprise', subtitle: 'Contact for quote', details: ['Everything in Team', ...EnterpriseBenefits], - buttonStyle: getButtonStyle(tier, 'enterprise'), - buttonLabel: getButtonLabel(tier, 'enterprise'), - isActive: tier === 'enterprise' + buttonStyle: getButtonStyle(billingTier, 'enterprise'), + buttonLabel: getButtonLabel(billingTier, 'enterprise'), + isActive: billingTier === 'enterprise' } ] as const diff --git a/packages/client/modules/userDashboard/components/OrgBilling/OrgPlansAndBilling.tsx b/packages/client/modules/userDashboard/components/OrgBilling/OrgPlansAndBilling.tsx index d680b8658db..dab1cf3309e 100644 --- a/packages/client/modules/userDashboard/components/OrgBilling/OrgPlansAndBilling.tsx +++ b/packages/client/modules/userDashboard/components/OrgBilling/OrgPlansAndBilling.tsx @@ -49,20 +49,20 @@ const OrgPlansAndBilling = (props: Props) => { ...BillingLeaders_organization ...PaymentDetails_organization ...OrgPlanDrawer_organization - tier + billingTier isBillingLeader } `, organizationRef ) const [hasSelectedTeamPlan, setHasSelectedTeamPlan] = useState(false) - const {tier, isBillingLeader} = organization + const {billingTier, isBillingLeader} = organization const handleSelectTeamPlan = () => { setHasSelectedTeamPlan(true) } - if (tier === 'starter') { + if (billingTier === 'starter') { return (
@@ -87,7 +87,7 @@ const OrgPlansAndBilling = (props: Props) => {
- {isBillingLeader && tier === 'team' && ( + {isBillingLeader && billingTier === 'team' && ( <> diff --git a/packages/client/modules/userDashboard/components/OrgBilling/OrgPlansAndBillingHeading.tsx b/packages/client/modules/userDashboard/components/OrgBilling/OrgPlansAndBillingHeading.tsx index c1d119506db..04bec647405 100644 --- a/packages/client/modules/userDashboard/components/OrgBilling/OrgPlansAndBillingHeading.tsx +++ b/packages/client/modules/userDashboard/components/OrgBilling/OrgPlansAndBillingHeading.tsx @@ -71,6 +71,7 @@ const OrgPlansAndBillingHeading = (props: Props) => { fragment OrgPlansAndBillingHeading_organization on Organization { id name + billingTier tier showDrawer } @@ -78,7 +79,7 @@ const OrgPlansAndBillingHeading = (props: Props) => { organizationRef ) const atmosphere = useAtmosphere() - const {id: orgId, name, tier} = organization + const {id: orgId, name, billingTier, tier} = organization const tierName = upperFirst(tier) const handleClick = () => { @@ -95,7 +96,9 @@ const OrgPlansAndBillingHeading = (props: Props) => { {'Plans & Billing'} {name} - {` is currently on the `} + {` is currently on ${ + tier !== billingTier ? 'a Free Trial for ' : '' + }the `} {`${tierName} Plan.`} diff --git a/packages/client/modules/userDashboard/components/Organization/Organization.tsx b/packages/client/modules/userDashboard/components/Organization/Organization.tsx index 7301abdca81..7d57d288312 100644 --- a/packages/client/modules/userDashboard/components/Organization/Organization.tsx +++ b/packages/client/modules/userDashboard/components/Organization/Organization.tsx @@ -90,6 +90,7 @@ const query = graphql` periodStart periodEnd tier + billingTier } } } @@ -111,7 +112,7 @@ const Organization = (props: Props) => { const orgName = (organization && organization.name) || 'Unknown' useDocumentTitle(`Organization Settings | ${orgName}`, orgName) if (!organization) return
- const {orgId, createdAt, isBillingLeader, picture: orgAvatar, tier} = organization + const {orgId, createdAt, isBillingLeader, picture: orgAvatar, tier, billingTier} = organization const pictureOrDefault = orgAvatar || defaultOrgAvatar const onlyShowMembers = !isBillingLeader && tier !== 'starter' const {checkoutFlow} = userFeatureFlags @@ -144,7 +145,7 @@ const Organization = (props: Props) => { ) : ( {orgName} )} - + {!onlyShowMembers && ( diff --git a/packages/client/modules/userDashboard/components/Organization/OrganizationDetails.tsx b/packages/client/modules/userDashboard/components/Organization/OrganizationDetails.tsx index 048c4247a5c..e6b0a2f8c10 100644 --- a/packages/client/modules/userDashboard/components/Organization/OrganizationDetails.tsx +++ b/packages/client/modules/userDashboard/components/Organization/OrganizationDetails.tsx @@ -20,17 +20,18 @@ const OrgDetails = styled('div')({ interface Props { createdAt: string tier: TierEnum + billingTier: TierEnum } const OrganizationDetails = (props: Props) => { - const {createdAt, tier} = props + const {createdAt, tier, billingTier} = props return ( {'Created '} {makeDateString(createdAt)} {tier !== 'starter' && ( - + )} diff --git a/packages/client/modules/userDashboard/components/OrganizationRow/OrganizationRow.tsx b/packages/client/modules/userDashboard/components/OrganizationRow/OrganizationRow.tsx index 9562b44accf..b3c6d77de41 100644 --- a/packages/client/modules/userDashboard/components/OrganizationRow/OrganizationRow.tsx +++ b/packages/client/modules/userDashboard/components/OrganizationRow/OrganizationRow.tsx @@ -100,6 +100,7 @@ const OrganizationRow = (props: Props) => { } picture tier + billingTier } `, organizationRef @@ -110,7 +111,8 @@ const OrganizationRow = (props: Props) => { name, orgUserCount: {activeUserCount, inactiveUserCount}, picture, - tier + tier, + billingTier } = organization const orgAvatar = picture || defaultOrgAvatar const onRowClick = () => { @@ -118,7 +120,7 @@ const OrganizationRow = (props: Props) => { history.push(`/me/organizations/${orgId}`) } const totalUsers = activeUserCount + inactiveUserCount - const showUpgradeCTA = tier === 'starter' + const showUpgradeCTA = billingTier === 'starter' const {tooltipPortal, openTooltip, closeTooltip, originRef} = useTooltip( MenuPosition.UPPER_CENTER ) @@ -133,7 +135,7 @@ const OrganizationRow = (props: Props) => { {name} {tier !== 'starter' && ( - + )} diff --git a/packages/client/modules/userDashboard/components/Organizations/Organizations.tsx b/packages/client/modules/userDashboard/components/Organizations/Organizations.tsx index b7fb449675a..a324f885835 100644 --- a/packages/client/modules/userDashboard/components/Organizations/Organizations.tsx +++ b/packages/client/modules/userDashboard/components/Organizations/Organizations.tsx @@ -27,7 +27,6 @@ const query = graphql` } name picture - tier } } } diff --git a/packages/client/types/constEnums.ts b/packages/client/types/constEnums.ts index 2ec2eda1364..d08529b9e18 100644 --- a/packages/client/types/constEnums.ts +++ b/packages/client/types/constEnums.ts @@ -384,7 +384,7 @@ export const enum Threshold { MASS_INVITATION_TOKEN_LIFESPAN = 2592000000, // 30 days TEAM_INVITATION_LIFESPAN = 2592000000, // 30 days FINAL_WARNING_DAYS_BEFORE_LOCK = 7, - MAX_FREE_TEAMS = 10, + MAX_FREE_TEAMS = 100, MAX_ACCOUNT_PASSWORD_ATTEMPTS = 10, MAX_ACCOUNT_DAILY_PASSWORD_RESETS = 3, MAX_AVATAR_FILE_SIZE = 1024 * 1024, diff --git a/packages/server/billing/helpers/teamLimitsCheck.ts b/packages/server/billing/helpers/teamLimitsCheck.ts index 71684d0655f..b9326c20e7c 100644 --- a/packages/server/billing/helpers/teamLimitsCheck.ts +++ b/packages/server/billing/helpers/teamLimitsCheck.ts @@ -17,6 +17,7 @@ import sendTeamsLimitEmail from './sendTeamsLimitEmail' import getTeamIdsByOrgIds from '../../postgres/queries/getTeamIdsByOrgIds' import getActiveTeamCountByTeamIds from '../../graphql/public/types/helpers/getActiveTeamCountByTeamIds' import {getBillingLeadersByOrgId} from '../../utils/getBillingLeadersByOrgId' +import {getFeatureTier} from '../../graphql/types/helpers/getFeatureTier' const enableUsageStats = async (userIds: string[], orgId: string) => { await r @@ -101,11 +102,11 @@ export const maybeRemoveRestrictions = async (orgId: string, dataLoader: DataLoa // Warning: the function might be expensive export const checkTeamsLimit = async (orgId: string, dataLoader: DataLoaderWorker) => { const organization = await dataLoader.get('organizations').load(orgId) - const {tierLimitExceededAt, tier, featureFlags, name: orgName} = organization + const {tierLimitExceededAt, tier, trialStartDate, featureFlags, name: orgName} = organization if (!featureFlags?.includes('teamsLimit')) return - if (tierLimitExceededAt || tier !== 'starter') return + if (tierLimitExceededAt || getFeatureTier({tier, trialStartDate}) !== 'starter') return // if an org is using a free provider, e.g. gmail.com, we can't show them usage stats, so don't send notifications/emails directing them there for now. Issue to fix this here: https://github.com/ParabolInc/parabol/issues/7723 if (!organization.activeDomain) return diff --git a/packages/server/database/types/Organization.ts b/packages/server/database/types/Organization.ts index 3683353d3f4..25e6e3bea63 100644 --- a/packages/server/database/types/Organization.ts +++ b/packages/server/database/types/Organization.ts @@ -35,6 +35,7 @@ export default class Organization { upcomingInvoiceEmailSentAt?: Date tier: TierEnum tierLimitExceededAt?: Date | null + trialStartDate?: Date | null scheduledLockAt?: Date | null lockedAt?: Date | null updatedAt: Date diff --git a/packages/server/database/types/OrganizationUser.ts b/packages/server/database/types/OrganizationUser.ts index 22812cca693..badd2bf6d1a 100644 --- a/packages/server/database/types/OrganizationUser.ts +++ b/packages/server/database/types/OrganizationUser.ts @@ -26,6 +26,7 @@ export default class OrganizationUser { role: OrgUserRole | null userId: string tier: TierEnum | null + trialStartDate?: Date | null constructor(input: Input) { const { diff --git a/packages/server/database/types/Team.ts b/packages/server/database/types/Team.ts index 4cbb812a083..f9a6e56c5c6 100644 --- a/packages/server/database/types/Team.ts +++ b/packages/server/database/types/Team.ts @@ -13,6 +13,7 @@ interface Input { isArchived?: boolean isPaid?: boolean tier: TierEnum + trialStartDate?: Date | null orgId: string qualAIMeetingsCount?: number isOnboardTeam?: boolean @@ -31,6 +32,7 @@ export default class Team { lastMeetingType: MeetingTypeEnum lockMessageHTML?: string | null tier: TierEnum + trialStartDate?: Date | null orgId: string isOnboardTeam: boolean isOneOnOneTeam?: boolean @@ -50,6 +52,7 @@ export default class Team { name, orgId, tier, + trialStartDate, qualAIMeetingsCount, updatedAt } = input @@ -58,6 +61,7 @@ export default class Team { this.createdBy = createdBy this.orgId = orgId this.tier = tier + this.trialStartDate = trialStartDate this.id = id ?? generateUID() this.createdAt = createdAt ?? new Date() this.updatedAt = updatedAt ?? new Date() diff --git a/packages/server/graphql/mutations/addPokerTemplate.ts b/packages/server/graphql/mutations/addPokerTemplate.ts index fd2c551ea2c..f7792ed21aa 100644 --- a/packages/server/graphql/mutations/addPokerTemplate.ts +++ b/packages/server/graphql/mutations/addPokerTemplate.ts @@ -11,6 +11,7 @@ import {GQLContext} from '../graphql' import AddPokerTemplatePayload from '../types/AddPokerTemplatePayload' import getTemplateIllustrationUrl from './helpers/getTemplateIllustrationUrl' import {analytics} from '../../utils/analytics/analytics' +import {getFeatureTier} from '../types/helpers/getFeatureTier' const addPokerTemplate = { description: 'Add a new poker template with a default dimension created', @@ -51,7 +52,10 @@ const addPokerTemplate = { if (!viewerTeam) { return standardError(new Error('Team not found'), {userId: viewerId}) } - if (viewerTeam.tier === 'starter' && !viewer.featureFlags.includes('noTemplateLimit')) { + if ( + getFeatureTier(viewerTeam) === 'starter' && + !viewer.featureFlags.includes('noTemplateLimit') + ) { return standardError(new Error('Creating templates is a premium feature'), {userId: viewerId}) } let data diff --git a/packages/server/graphql/mutations/addReflectTemplate.ts b/packages/server/graphql/mutations/addReflectTemplate.ts index 9b24849f0ed..0aa2252d642 100644 --- a/packages/server/graphql/mutations/addReflectTemplate.ts +++ b/packages/server/graphql/mutations/addReflectTemplate.ts @@ -12,6 +12,7 @@ import {GQLContext} from '../graphql' import AddReflectTemplatePayload from '../types/AddReflectTemplatePayload' import makeRetroTemplates from './helpers/makeRetroTemplates' import {analytics} from '../../utils/analytics/analytics' +import {getFeatureTier} from '../types/helpers/getFeatureTier' const addReflectTemplate = { description: 'Add a new template full of prompts', @@ -52,7 +53,10 @@ const addReflectTemplate = { if (!viewerTeam) { return standardError(new Error('Team not found'), {userId: viewerId}) } - if (viewerTeam.tier === 'starter' && !viewer.featureFlags.includes('noTemplateLimit')) { + if ( + getFeatureTier(viewerTeam) === 'starter' && + !viewer.featureFlags.includes('noTemplateLimit') + ) { return standardError(new Error('Creating templates is a premium feature'), {userId: viewerId}) } let data diff --git a/packages/server/graphql/mutations/addTeam.ts b/packages/server/graphql/mutations/addTeam.ts index 9aa96f0a65b..70b022966bb 100644 --- a/packages/server/graphql/mutations/addTeam.ts +++ b/packages/server/graphql/mutations/addTeam.ts @@ -18,6 +18,7 @@ import addTeamValidation from './helpers/addTeamValidation' import createTeamAndLeader from './helpers/createTeamAndLeader' import inviteToTeamHelper from './helpers/inviteToTeamHelper' import {analytics} from '../../utils/analytics/analytics' +import {getFeatureTier} from '../types/helpers/getFeatureTier' export default { type: new GraphQLNonNull(AddTeamPayload), @@ -74,8 +75,7 @@ export default { } if (orgTeams.length >= Threshold.MAX_FREE_TEAMS) { const organization = await dataLoader.get('organizations').load(orgId) - const {tier} = organization - if (tier === 'starter') { + if (getFeatureTier(organization) === 'starter') { return standardError(new Error('Max free teams reached'), {userId: viewerId}) } } diff --git a/packages/server/graphql/mutations/createReflection.ts b/packages/server/graphql/mutations/createReflection.ts index 74dec11417e..8369c731585 100644 --- a/packages/server/graphql/mutations/createReflection.ts +++ b/packages/server/graphql/mutations/createReflection.ts @@ -19,6 +19,7 @@ import CreateReflectionPayload from '../types/CreateReflectionPayload' import getReflectionEntities from './helpers/getReflectionEntities' import getReflectionSentimentScore from './helpers/getReflectionSentimentScore' import {analytics} from '../../utils/analytics/analytics' +import {getFeatureTier} from '../types/helpers/getFeatureTier' export default { type: CreateReflectionPayload, @@ -53,7 +54,6 @@ export default { return {error: {message: 'Meeting already ended'}} } const team = await dataLoader.get('teams').loadNonNull(teamId) - const {tier} = team if (isPhaseComplete('group', phases)) { return standardError(new Error('Meeting phase already completed'), {userId: viewerId}) } @@ -65,7 +65,9 @@ export default { const plaintextContent = extractTextFromDraftString(normalizedContent) const [entities, sentimentScore] = await Promise.all([ getReflectionEntities(plaintextContent), - tier !== 'starter' ? getReflectionSentimentScore(question, plaintextContent) : undefined + getFeatureTier(team) !== 'starter' + ? getReflectionSentimentScore(question, plaintextContent) + : undefined ]) const reflectionGroupId = generateUID() diff --git a/packages/server/graphql/mutations/helpers/addAIGeneratedContentToThreads.ts b/packages/server/graphql/mutations/helpers/addAIGeneratedContentToThreads.ts index a9829f10f67..1254d4890b7 100644 --- a/packages/server/graphql/mutations/helpers/addAIGeneratedContentToThreads.ts +++ b/packages/server/graphql/mutations/helpers/addAIGeneratedContentToThreads.ts @@ -5,6 +5,7 @@ import Comment from '../../../database/types/Comment' import DiscussStage from '../../../database/types/DiscussStage' import {convertHtmlToTaskContent} from '../../../utils/draftjs/convertHtmlToTaskContent' import {DataLoaderWorker} from '../../graphql' +import {getFeatureTier} from '../../types/helpers/getFeatureTier' const buildCommentContentBlock = (title: string, content: string, explainerText?: string) => { const explainerBlock = explainerText ? `${explainerText}
` : '' @@ -31,7 +32,6 @@ const addAIGeneratedContentToThreads = async ( dataLoader.get('retroReflectionGroupsByMeetingId').load(meetingId), dataLoader.get('teams').loadNonNull(teamId) ]) - const {tier} = team const commentPromises = stages.map(async ({discussionId, reflectionGroupId}, idx) => { const group = groups.find((group) => group.id === reflectionGroupId) if (!group?.summary && !group?.discussionPromptQuestion) return @@ -40,7 +40,7 @@ const addAIGeneratedContentToThreads = async ( if (group.summary) { const topicSummaryExplainerText = idx === 0 - ? tier === 'starter' + ? getFeatureTier(team) === 'starter' ? AIExplainer.STARTER : AIExplainer.PREMIUM_REFLECTIONS : undefined diff --git a/packages/server/graphql/mutations/helpers/canAccessAISummary.ts b/packages/server/graphql/mutations/helpers/canAccessAISummary.ts index 64c88d3aa4b..a3fdb939d46 100644 --- a/packages/server/graphql/mutations/helpers/canAccessAISummary.ts +++ b/packages/server/graphql/mutations/helpers/canAccessAISummary.ts @@ -1,6 +1,7 @@ import {Threshold} from 'parabol-client/types/constEnums' import {Team} from '../../../postgres/queries/getTeamsByIds' import {DataLoaderWorker} from '../../graphql' +import {getFeatureTier} from '../../types/helpers/getFeatureTier' const canAccessAISummary = async ( team: Team, @@ -9,7 +10,7 @@ const canAccessAISummary = async ( meetingType: 'standup' | 'retrospective' ) => { if (featureFlags.includes('noAISummary') || !team) return false - const {qualAIMeetingsCount, tier, orgId} = team + const {qualAIMeetingsCount, orgId} = team const organization = await dataLoader.get('organizations').load(orgId) if (organization.featureFlags?.includes('noAISummary')) return false if (meetingType === 'standup') { @@ -17,7 +18,7 @@ const canAccessAISummary = async ( return true } - if (tier !== 'starter') return true + if (getFeatureTier(team) !== 'starter') return true return qualAIMeetingsCount < Threshold.MAX_QUAL_AI_MEETINGS } diff --git a/packages/server/graphql/mutations/helpers/createNewMeetingPhases.ts b/packages/server/graphql/mutations/helpers/createNewMeetingPhases.ts index 5176302500c..31567d1a6d2 100644 --- a/packages/server/graphql/mutations/helpers/createNewMeetingPhases.ts +++ b/packages/server/graphql/mutations/helpers/createNewMeetingPhases.ts @@ -28,6 +28,7 @@ import insertDiscussions from '../../../postgres/queries/insertDiscussions' import {MeetingTypeEnum} from '../../../postgres/types/Meeting' import {DataLoaderWorker} from '../../graphql' import isPhaseAvailable from '../../../utils/isPhaseAvailable' +import {getFeatureTier} from '../../types/helpers/getFeatureTier' export const primePhases = (phases: GenericMeetingPhase[], startIndex = 0) => { const [firstPhase, secondPhase] = [phases[startIndex], phases[startIndex + 1]] @@ -80,13 +81,13 @@ const createNewMeetingPhases = async ( const [meetingSettings, stageDurations, team] = await Promise.all([ dataLoader.get('meetingSettingsByType').load({teamId, meetingType}), getPastStageDurations(teamId), - dataLoader.get('teams').load(teamId) + dataLoader.get('teams').loadNonNull(teamId) ]) const {phaseTypes} = meetingSettings const facilitatorTeamMemberId = toTeamMemberId(teamId, facilitatorUserId) const asyncSideEffects = [] as Promise[] - const {tier = 'starter'} = team ?? {} + const tier = getFeatureTier(team) const phases = (await Promise.all( phaseTypes.filter(isPhaseAvailable(tier)).map(async (phaseType) => { const durations = stageDurations[phaseType] diff --git a/packages/server/graphql/mutations/helpers/createTeamAndLeader.ts b/packages/server/graphql/mutations/helpers/createTeamAndLeader.ts index 67c58994853..1daa2d8730e 100644 --- a/packages/server/graphql/mutations/helpers/createTeamAndLeader.ts +++ b/packages/server/graphql/mutations/helpers/createTeamAndLeader.ts @@ -25,8 +25,8 @@ export default async function createTeamAndLeader(user: IUser, newTeam: ValidNew const {id: userId} = user const {id: teamId, orgId} = newTeam const organization = await r.table('Organization').get(orgId).run() - const {tier} = organization - const verifiedTeam = new Team({...newTeam, createdBy: userId, tier}) + const {tier, trialStartDate} = organization + const verifiedTeam = new Team({...newTeam, createdBy: userId, tier, trialStartDate}) const meetingSettings = [ new MeetingSettingsRetrospective({teamId}), new MeetingSettingsAction({teamId}), diff --git a/packages/server/graphql/mutations/helpers/oldUpgradeToTeamTier.ts b/packages/server/graphql/mutations/helpers/oldUpgradeToTeamTier.ts index d3cd242f243..53d4f9552e0 100644 --- a/packages/server/graphql/mutations/helpers/oldUpgradeToTeamTier.ts +++ b/packages/server/graphql/mutations/helpers/oldUpgradeToTeamTier.ts @@ -1,6 +1,6 @@ import removeTeamsLimitObjects from '../../../billing/helpers/removeTeamsLimitObjects' import getRethink from '../../../database/rethinkDriver' -import updateTeamByOrgId from '../../../postgres/queries/updateTeamByOrgId' +import getKysely from '../../../postgres/getKysely' import {fromEpochSeconds} from '../../../utils/epochTime' import setTierForOrgUsers from '../../../utils/setTierForOrgUsers' import setUserTierForOrgId from '../../../utils/setUserTierForOrgId' @@ -15,6 +15,7 @@ const oldUpgradeToTeamTier = async ( dataLoader: DataLoaderWorker ) => { const r = await getRethink() + const pg = getKysely() const now = new Date() const organization = await r.table('Organization').get(orgId).run() @@ -55,7 +56,8 @@ const oldUpgradeToTeamTier = async ( tierLimitExceededAt: null, scheduledLockAt: null, lockedAt: null, - updatedAt: now + updatedAt: now, + trialStartDate: null }) }).run() @@ -75,13 +77,15 @@ const oldUpgradeToTeamTier = async ( } await Promise.all([ - updateTeamByOrgId( - { + pg + .updateTable('Team') + .set({ isPaid: true, - tier: 'team' - }, - orgId - ), + tier: 'team', + trialStartDate: null + }) + .where('orgId', '=', orgId) + .execute(), removeTeamsLimitObjects(orgId, dataLoader) ]) diff --git a/packages/server/graphql/mutations/moveTeamToOrg.ts b/packages/server/graphql/mutations/moveTeamToOrg.ts index 93974767ae9..7000a3f1013 100644 --- a/packages/server/graphql/mutations/moveTeamToOrg.ts +++ b/packages/server/graphql/mutations/moveTeamToOrg.ts @@ -84,6 +84,7 @@ const moveToOrg = async ( orgId, isPaid: !!isPaidResult?.isPaid, tier: org.tier, + trialStartDate: org.trialStartDate, updatedAt: new Date() } const [rethinkResult] = await Promise.all([ diff --git a/packages/server/graphql/mutations/selectTemplate.ts b/packages/server/graphql/mutations/selectTemplate.ts index 1264df32f14..5877f848b01 100644 --- a/packages/server/graphql/mutations/selectTemplate.ts +++ b/packages/server/graphql/mutations/selectTemplate.ts @@ -7,6 +7,7 @@ import publish from '../../utils/publish' import standardError from '../../utils/standardError' import {GQLContext} from '../graphql' import SelectTemplatePayload from '../types/SelectTemplatePayload' +import {getFeatureTier} from '../types/helpers/getFeatureTier' const selectTemplate = { description: 'Set the selected template for the upcoming retro meeting', @@ -54,7 +55,7 @@ const selectTemplate = { if ( !isFree && !viewer.featureFlags.includes('noTemplateLimit') && - viewerTeam.tier === 'starter' + getFeatureTier(viewerTeam) === 'starter' ) { return standardError(new Error('User does not have access to this premium template'), { userId: viewerId diff --git a/packages/server/graphql/mutations/updateReflectionContent.ts b/packages/server/graphql/mutations/updateReflectionContent.ts index 9dea62e3ff3..7d4d35ef948 100644 --- a/packages/server/graphql/mutations/updateReflectionContent.ts +++ b/packages/server/graphql/mutations/updateReflectionContent.ts @@ -15,6 +15,7 @@ import UpdateReflectionContentPayload from '../types/UpdateReflectionContentPayl import getReflectionEntities from './helpers/getReflectionEntities' import getReflectionSentimentScore from './helpers/getReflectionSentimentScore' import updateSmartGroupTitle from './helpers/updateReflectionLocation/updateSmartGroupTitle' +import {getFeatureTier} from '../types/helpers/getFeatureTier' export default { type: UpdateReflectionContentPayload, @@ -56,7 +57,6 @@ export default { return standardError(new Error('Team not found'), {userId: viewerId}) } const team = await dataLoader.get('teams').loadNonNull(teamId) - const {tier} = team if (endedAt) return standardError(new Error('Meeting already ended'), {userId: viewerId}) if (isPhaseComplete('group', phases)) { return standardError(new Error('Meeting phase already ended'), {userId: viewerId}) @@ -76,7 +76,7 @@ export default { ? await getReflectionEntities(plaintextContent) : reflection.entities const sentimentScore = - tier !== 'starter' + getFeatureTier(team) !== 'starter' ? isVeryDifferent ? await getReflectionSentimentScore(question, plaintextContent) : reflection.sentimentScore diff --git a/packages/server/graphql/private/mutations/draftEnterpriseInvoice.ts b/packages/server/graphql/private/mutations/draftEnterpriseInvoice.ts index 9bfae931e21..43052601fae 100644 --- a/packages/server/graphql/private/mutations/draftEnterpriseInvoice.ts +++ b/packages/server/graphql/private/mutations/draftEnterpriseInvoice.ts @@ -1,6 +1,5 @@ import getRethink from '../../../database/rethinkDriver' import {getUserByEmail} from '../../../postgres/queries/getUsersByEmails' -import updateTeamByOrgId from '../../../postgres/queries/updateTeamByOrgId' import IUser from '../../../postgres/types/IUser' import {analytics} from '../../../utils/analytics/analytics' import {fromEpochSeconds} from '../../../utils/epochTime' @@ -12,6 +11,7 @@ import isValid from '../../isValid' import hideConversionModal from '../../mutations/helpers/hideConversionModal' import {MutationResolvers} from '../resolverTypes' import removeTeamsLimitObjects from '../../../billing/helpers/removeTeamsLimitObjects' +import getKysely from '../../../postgres/getKysely' const getBillingLeaderUser = async ( email: string | null | undefined, @@ -57,6 +57,7 @@ const draftEnterpriseInvoice: MutationResolvers['draftEnterpriseInvoice'] = asyn {dataLoader} ) => { const r = await getRethink() + const pg = getKysely() const now = new Date() // VALIDATION @@ -123,16 +124,19 @@ const draftEnterpriseInvoice: MutationResolvers['draftEnterpriseInvoice'] = asyn tierLimitExceededAt: null, scheduledLockAt: null, lockedAt: null, - updatedAt: now + updatedAt: now, + trialStartDate: null }) }).run(), - updateTeamByOrgId( - { + pg + .updateTable('Team') + .set({ isPaid: true, - tier: 'enterprise' - }, - orgId - ), + tier: 'enterprise', + trialStartDate: null + }) + .where('orgId', '=', orgId) + .execute(), removeTeamsLimitObjects(orgId, dataLoader) ]) @@ -145,6 +149,7 @@ const draftEnterpriseInvoice: MutationResolvers['draftEnterpriseInvoice'] = asyn orgId, domain: org.activeDomain, orgName: org.name, + isTrial: !!org.trialStartDate, oldTier: 'starter', newTier: 'enterprise', billingLeaderEmail: user.email diff --git a/packages/server/graphql/private/mutations/endTrial.ts b/packages/server/graphql/private/mutations/endTrial.ts new file mode 100644 index 00000000000..fce952a0eaa --- /dev/null +++ b/packages/server/graphql/private/mutations/endTrial.ts @@ -0,0 +1,39 @@ +import getRethink from '../../../database/rethinkDriver' +import getKysely from '../../../postgres/getKysely' +import setTierForOrgUsers from '../../../utils/setTierForOrgUsers' +import setUserTierForOrgId from '../../../utils/setUserTierForOrgId' +import standardError from '../../../utils/standardError' +import {MutationResolvers} from '../resolverTypes' + +const endTrial: MutationResolvers['endTrial'] = async (_source, {orgId}, {dataLoader}) => { + const now = new Date() + const r = await getRethink() + const pg = getKysely() + + const organization = await dataLoader.get('organizations').load(orgId) + + // VALIDATION + if (!organization.trialStartDate) { + return standardError(new Error('No trial active for org')) + } + + // RESOLUTION + await Promise.all([ + r({ + orgUpdate: r.table('Organization').get(orgId).update({ + trialStartDate: null, + updatedAt: now + }) + }).run(), + pg.updateTable('Team').set({trialStartDate: null}).where('orgId', '=', orgId).execute() + ]) + + const initialTrialStartDate = organization.trialStartDate + organization.trialStartDate = null + + await Promise.all([setUserTierForOrgId(orgId), setTierForOrgUsers(orgId)]) + + return {organization, trialStartDate: initialTrialStartDate} +} + +export default endTrial diff --git a/packages/server/graphql/private/mutations/startTrial.ts b/packages/server/graphql/private/mutations/startTrial.ts new file mode 100644 index 00000000000..af4e52efb61 --- /dev/null +++ b/packages/server/graphql/private/mutations/startTrial.ts @@ -0,0 +1,49 @@ +import removeTeamsLimitObjects from '../../../billing/helpers/removeTeamsLimitObjects' +import getRethink from '../../../database/rethinkDriver' +import getKysely from '../../../postgres/getKysely' +import setTierForOrgUsers from '../../../utils/setTierForOrgUsers' +import setUserTierForOrgId from '../../../utils/setUserTierForOrgId' +import standardError from '../../../utils/standardError' +import hideConversionModal from '../../mutations/helpers/hideConversionModal' +import {MutationResolvers} from '../resolverTypes' + +const startTrial: MutationResolvers['startTrial'] = async (_source, {orgId}, {dataLoader}) => { + const r = await getRethink() + const pg = getKysely() + const now = new Date() + const organization = await dataLoader.get('organizations').load(orgId) + + // VALIDATION + if (organization.tier !== 'starter') { + return standardError(new Error('Cannot start trial for organization on paid tier')) + } + if (organization.trialStartDate) { + return standardError( + new Error(`Trial already started for org. Start date: ${organization.trialStartDate}`) + ) + } + + // RESOLUTION + await Promise.all([ + r({ + updatedOrg: r.table('Organization').get(orgId).update({ + trialStartDate: now, + tierLimitExceededAt: null, + scheduledLockAt: null, + lockedAt: null, + updatedAt: now + }) + }).run(), + pg.updateTable('Team').set({trialStartDate: now}).where('orgId', '=', orgId).execute(), + removeTeamsLimitObjects(orgId, dataLoader) + ]) + organization.trialStartDate = now + + await Promise.all([setUserTierForOrgId(orgId), setTierForOrgUsers(orgId)]) + + await hideConversionModal(orgId, dataLoader) + + return {organization} +} + +export default startTrial diff --git a/packages/server/graphql/private/mutations/upgradeToTeamTier.ts b/packages/server/graphql/private/mutations/upgradeToTeamTier.ts index 10f43e74f01..6960fb62bb1 100644 --- a/packages/server/graphql/private/mutations/upgradeToTeamTier.ts +++ b/packages/server/graphql/private/mutations/upgradeToTeamTier.ts @@ -1,7 +1,6 @@ import {SubscriptionChannel} from 'parabol-client/types/constEnums' import removeTeamsLimitObjects from '../../../billing/helpers/removeTeamsLimitObjects' import getRethink from '../../../database/rethinkDriver' -import updateTeamByOrgId from '../../../postgres/queries/updateTeamByOrgId' import {analytics} from '../../../utils/analytics/analytics' import {getUserId} from '../../../utils/authorization' import publish from '../../../utils/publish' @@ -12,6 +11,7 @@ import {getStripeManager} from '../../../utils/stripe' import getCCFromCustomer from '../../mutations/helpers/getCCFromCustomer' import hideConversionModal from '../../mutations/helpers/hideConversionModal' import {MutationResolvers} from '../resolverTypes' +import getKysely from '../../../postgres/getKysely' // included here to codegen has access to it export type UpgradeToTeamTierSuccessSource = { @@ -39,6 +39,7 @@ const upgradeToTeamTier: MutationResolvers['upgradeToTeamTier'] = async ( } const r = await getRethink() + const pg = getKysely() const operationId = dataLoader.share() const subOptions = {mutatorId, operationId} const now = new Date() @@ -46,7 +47,14 @@ const upgradeToTeamTier: MutationResolvers['upgradeToTeamTier'] = async ( // AUTH const viewerId = getUserId(authToken) const organization = await dataLoader.get('organizations').load(orgId) - const {stripeId, tier, activeDomain, name: orgName, stripeSubscriptionId} = organization + const { + stripeId, + tier, + activeDomain, + name: orgName, + stripeSubscriptionId, + trialStartDate + } = organization if (!stripeId) { return standardError(new Error('Organization does not have a stripe id'), { @@ -76,16 +84,19 @@ const upgradeToTeamTier: MutationResolvers['upgradeToTeamTier'] = async ( tierLimitExceededAt: null, scheduledLockAt: null, lockedAt: null, - updatedAt: now + updatedAt: now, + trialStartDate: null }) }).run(), - updateTeamByOrgId( - { + pg + .updateTable('Team') + .set({ isPaid: true, - tier: 'team' - }, - orgId - ), + tier: 'team', + trialStartDate: null + }) + .where('orgId', '=', orgId) + .execute(), removeTeamsLimitObjects(orgId, dataLoader) ]) organization.tier = 'team' @@ -107,6 +118,7 @@ const upgradeToTeamTier: MutationResolvers['upgradeToTeamTier'] = async ( analytics.organizationUpgraded(viewerId, { orgId, domain: activeDomain, + isTrial: !!trialStartDate, orgName, oldTier: 'starter', newTier: 'team' diff --git a/packages/server/graphql/private/typeDefs/endTrial.graphql b/packages/server/graphql/private/typeDefs/endTrial.graphql new file mode 100644 index 00000000000..200565c65d3 --- /dev/null +++ b/packages/server/graphql/private/typeDefs/endTrial.graphql @@ -0,0 +1,28 @@ +extend type Mutation { + """ + Ends the trial for an org iff there is an active trial. + """ + endTrial( + """ + ID of the org on which to end the trial + """ + orgId: ID! + ): EndTrialPayload! +} + +""" +Return value for endTrial, which could be an error +""" +union EndTrialPayload = ErrorPayload | EndTrialSuccess + +type EndTrialSuccess { + """ + The updated organization + """ + organization: Organization! + + """ + The start time of the ended trial + """ + trialStartDate: DateTime +} diff --git a/packages/server/graphql/private/typeDefs/startTrial.graphql b/packages/server/graphql/private/typeDefs/startTrial.graphql new file mode 100644 index 00000000000..5b51c754da0 --- /dev/null +++ b/packages/server/graphql/private/typeDefs/startTrial.graphql @@ -0,0 +1,23 @@ +extend type Mutation { + """ + Starts a free trial for the org iff the org is on a free tier. + """ + startTrial( + """ + ID of the org on which to start the trial + """ + orgId: ID! + ): StartTrialPayload! +} + +""" +Return value for startTrial, which could be an error +""" +union StartTrialPayload = ErrorPayload | StartTrialSuccess + +type StartTrialSuccess { + """ + The updated organization + """ + organization: Organization! +} diff --git a/packages/server/graphql/private/types/EndTrialSuccess.ts b/packages/server/graphql/private/types/EndTrialSuccess.ts new file mode 100644 index 00000000000..80c645bbfab --- /dev/null +++ b/packages/server/graphql/private/types/EndTrialSuccess.ts @@ -0,0 +1,11 @@ +import Organization from '../../../database/types/Organization' +import {EndTrialSuccessResolvers} from '../resolverTypes' + +export type EndTrialSuccessSource = { + organization: Organization + trialStartDate: Date +} + +const EndTrialSuccess: EndTrialSuccessResolvers = {} + +export default EndTrialSuccess diff --git a/packages/server/graphql/private/types/StartTrialSuccess.ts b/packages/server/graphql/private/types/StartTrialSuccess.ts new file mode 100644 index 00000000000..b166c9164d2 --- /dev/null +++ b/packages/server/graphql/private/types/StartTrialSuccess.ts @@ -0,0 +1,10 @@ +import Organization from '../../../database/types/Organization' +import {StartTrialSuccessResolvers} from '../resolverTypes' + +export type StartTrialSuccessSource = { + organization: Organization +} + +const StartTrialSuccess: StartTrialSuccessResolvers = {} + +export default StartTrialSuccess diff --git a/packages/server/graphql/public/typeDefs/Organization.graphql b/packages/server/graphql/public/typeDefs/Organization.graphql index 96740ec5ca0..412f95e1505 100644 --- a/packages/server/graphql/public/typeDefs/Organization.graphql +++ b/packages/server/graphql/public/typeDefs/Organization.graphql @@ -57,10 +57,14 @@ type Organization { """ teams: [Team!]! + tier: TierEnum! + + billingTier: TierEnum! + """ - The level of access to features on the parabol site + When the trial started, iff there is a trial active """ - tier: TierEnum! + trialStartDate: DateTime """ THe datetime the current billing cycle ends diff --git a/packages/server/graphql/public/typeDefs/Team.graphql b/packages/server/graphql/public/typeDefs/Team.graphql index cd48a26f748..93358736b2e 100644 --- a/packages/server/graphql/public/typeDefs/Team.graphql +++ b/packages/server/graphql/public/typeDefs/Team.graphql @@ -128,10 +128,10 @@ type Team { meetingId: ID! ): NewMeeting - """ - The level of access to features on the parabol site - """ tier: TierEnum! + + billingTier: TierEnum! + organization: Organization! """ diff --git a/packages/server/graphql/public/typeDefs/User.graphql b/packages/server/graphql/public/typeDefs/User.graphql index f7221e958a9..232c2027391 100644 --- a/packages/server/graphql/public/typeDefs/User.graphql +++ b/packages/server/graphql/public/typeDefs/User.graphql @@ -403,11 +403,10 @@ type User { userId: ID ): TeamMember - """ - The highest tier of any org the user belongs to - """ tier: TierEnum! + billingTier: TierEnum! + """ all the teams the user is a part of that the viewer can see """ diff --git a/packages/server/graphql/public/typeDefs/_legacy.graphql b/packages/server/graphql/public/typeDefs/_legacy.graphql index 3298dd68269..6deb835773e 100644 --- a/packages/server/graphql/public/typeDefs/_legacy.graphql +++ b/packages/server/graphql/public/typeDefs/_legacy.graphql @@ -2184,10 +2184,9 @@ type OrganizationUser { """ user: User! - """ - Their level of access to features on the parabol site - """ tier: TierEnum + + billingTier: TierEnum } """ diff --git a/packages/server/graphql/public/types/Organization.ts b/packages/server/graphql/public/types/Organization.ts index f89373068e9..617cc39d5ec 100644 --- a/packages/server/graphql/public/types/Organization.ts +++ b/packages/server/graphql/public/types/Organization.ts @@ -5,6 +5,7 @@ import {getUserByEmail} from '../../../postgres/queries/getUsersByEmails' import {getExistingOneOnOneTeam} from '../../mutations/helpers/getExistingOneOnOneTeam' import {mapToTeam} from '../../../postgres/queries/getTeamsByIds' import {IGetTeamsByIdsQueryResult} from '../../../postgres/queries/generated/getTeamsByIdsQuery' +import {getFeatureTier} from '../../types/helpers/getFeatureTier' const Organization: OrganizationResolvers = { approvedDomains: async ({id: orgId}, _args, {dataLoader}) => { @@ -24,6 +25,10 @@ const Organization: OrganizationResolvers = { if (!featureFlags) return {} return Object.fromEntries(featureFlags.map((flag) => [flag as any, true])) }, + tier: ({tier, trialStartDate}) => { + return getFeatureTier({tier, trialStartDate}) + }, + billingTier: ({tier}) => tier, oneOnOneTeam: async ( {id: orgId}: {id: string}, {email}: {email: string}, diff --git a/packages/server/graphql/public/types/OrganizationUser.ts b/packages/server/graphql/public/types/OrganizationUser.ts new file mode 100644 index 00000000000..380a639b3fc --- /dev/null +++ b/packages/server/graphql/public/types/OrganizationUser.ts @@ -0,0 +1,11 @@ +import {getFeatureTier} from '../../types/helpers/getFeatureTier' +import {OrganizationUserResolvers} from '../resolverTypes' + +const OrganizationUser: OrganizationUserResolvers = { + tier: ({tier, trialStartDate}) => { + return tier ? getFeatureTier({tier, trialStartDate}) : tier + }, + billingTier: ({tier}) => tier +} + +export default OrganizationUser diff --git a/packages/server/graphql/public/types/Team.ts b/packages/server/graphql/public/types/Team.ts index d3e8afe7a0d..93065f81416 100644 --- a/packages/server/graphql/public/types/Team.ts +++ b/packages/server/graphql/public/types/Team.ts @@ -2,6 +2,7 @@ import {TeamResolvers} from '../resolverTypes' import TeamInsightsId from 'parabol-client/shared/gqlIds/TeamInsightsId' import toTeamMemberId from '../../../../client/utils/relay/toTeamMemberId' import {getUserId, isTeamMember} from '../../../utils/authorization' +import {getFeatureTier} from '../../types/helpers/getFeatureTier' const Team: TeamResolvers = { insights: async ( @@ -35,7 +36,11 @@ const Team: TeamResolvers = { const teamMember = await dataLoader.get('teamMembers').load(teamMemberId) return teamMember }, - isViewerOnTeam: async ({id: teamId}, _args, {authToken}) => isTeamMember(authToken, teamId) + isViewerOnTeam: async ({id: teamId}, _args, {authToken}) => isTeamMember(authToken, teamId), + tier: ({tier, trialStartDate}) => { + return getFeatureTier({tier, trialStartDate}) + }, + billingTier: ({tier}) => tier } export default Team diff --git a/packages/server/graphql/public/types/User.ts b/packages/server/graphql/public/types/User.ts index 6ddb6a5bcd2..adf7b9457a6 100644 --- a/packages/server/graphql/public/types/User.ts +++ b/packages/server/graphql/public/types/User.ts @@ -22,6 +22,7 @@ import connectionFromTemplateArray from '../../queries/helpers/connectionFromTem import getSignOnURL from '../mutations/helpers/SAMLHelpers/getSignOnURL' import {UserResolvers} from '../resolverTypes' import {getSSOMetadataFromURL} from '../../../utils/getSSOMetadataFromURL' +import {getFeatureTier} from '../../types/helpers/getFeatureTier' declare const __PRODUCTION__: string @@ -191,7 +192,11 @@ const User: UserResolvers = { const urlObj = new URL(baseUrl) urlObj.searchParams.append('RelayState', relayState) return {url: urlObj.toString()} - } + }, + tier: ({tier, trialStartDate}) => { + return getFeatureTier({tier, trialStartDate}) + }, + billingTier: ({tier}) => tier } export default User diff --git a/packages/server/graphql/queries/helpers/resolveSelectedTemplate.ts b/packages/server/graphql/queries/helpers/resolveSelectedTemplate.ts index aa57923b70d..4924d2459ce 100644 --- a/packages/server/graphql/queries/helpers/resolveSelectedTemplate.ts +++ b/packages/server/graphql/queries/helpers/resolveSelectedTemplate.ts @@ -4,6 +4,7 @@ import MeetingSettingsRetrospective from '../../../database/types/MeetingSetting import {GQLContext} from '../../graphql' import {getUserId} from '../../../utils/authorization' import isValid from '../../isValid' +import {getFeatureTier} from '../../types/helpers/getFeatureTier' const resolveSelectedTemplate = (fallbackTemplateId: string) => @@ -19,12 +20,11 @@ const resolveSelectedTemplate = dataLoader.get('meetingTemplates').load(selectedTemplateId), dataLoader.get('users').loadNonNull(viewerId) ]) - const {tier} = team if (template) { if ( template.isFree || template.scope !== 'PUBLIC' || - tier !== 'starter' || + getFeatureTier(team) !== 'starter' || viewer.featureFlags.includes('noTemplateLimit') ) { return template diff --git a/packages/server/graphql/types/Organization.ts b/packages/server/graphql/types/Organization.ts index b639731ca31..5f8d56fc206 100644 --- a/packages/server/graphql/types/Organization.ts +++ b/packages/server/graphql/types/Organization.ts @@ -17,7 +17,6 @@ import GraphQLURLType from './GraphQLURLType' import OrganizationUser, {OrganizationUserConnection} from './OrganizationUser' import OrgUserCount from './OrgUserCount' import Team from './Team' -import TierEnum from './TierEnum' import User from './User' const Organization: GraphQLObjectType = new GraphQLObjectType({ @@ -98,10 +97,6 @@ const Organization: GraphQLObjectType = new GraphQLObjectType
authToken.tms.includes(team.id)) } }, - tier: { - type: new GraphQLNonNull(TierEnum), - description: 'The level of access to features on the parabol site' - }, periodEnd: { type: GraphQLISO8601Type, description: 'THe datetime the current billing cycle ends', diff --git a/packages/server/graphql/types/OrganizationUser.ts b/packages/server/graphql/types/OrganizationUser.ts index b38e38e4f8f..3940c23fc9f 100644 --- a/packages/server/graphql/types/OrganizationUser.ts +++ b/packages/server/graphql/types/OrganizationUser.ts @@ -5,7 +5,6 @@ import {resolveOrganization} from '../resolvers' import GraphQLISO8601Type from './GraphQLISO8601Type' import Organization from './Organization' import OrgUserRole from './OrgUserRole' -import TierEnum from './TierEnum' import User from './User' const OrganizationUser = new GraphQLObjectType({ @@ -57,10 +56,6 @@ const OrganizationUser = new GraphQLObjectType({ resolve: async ({userId}, _args, {dataLoader}) => { return dataLoader.get('users').load(userId) } - }, - tier: { - type: TierEnum, - description: 'Their level of access to features on the parabol site' } }) }) diff --git a/packages/server/graphql/types/Team.ts b/packages/server/graphql/types/Team.ts index d60f4058c37..aef9d9a38ce 100644 --- a/packages/server/graphql/types/Team.ts +++ b/packages/server/graphql/types/Team.ts @@ -30,7 +30,6 @@ import TeamInvitation from './TeamInvitation' import TeamMeetingSettings from './TeamMeetingSettings' import TeamMember from './TeamMember' import TemplateScale from './TemplateScale' -import TierEnum from './TierEnum' const Team: GraphQLObjectType = new GraphQLObjectType({ name: 'Team', @@ -253,10 +252,6 @@ const Team: GraphQLObjectType = new GraphQLObjectType({ return null } }, - tier: { - type: new GraphQLNonNull(TierEnum), - description: 'The level of access to features on the parabol site' - }, organization: { type: new GraphQLNonNull(Organization), resolve: async ( diff --git a/packages/server/graphql/types/TeamMeetingSettings.ts b/packages/server/graphql/types/TeamMeetingSettings.ts index f3e7df59c74..326e43b1f37 100644 --- a/packages/server/graphql/types/TeamMeetingSettings.ts +++ b/packages/server/graphql/types/TeamMeetingSettings.ts @@ -11,6 +11,7 @@ import Team from './Team' import TeamPromptMeetingSettings from './TeamPromptMeetingSettings' import isPhaseAvailable from '../../utils/isPhaseAvailable' import {GQLContext} from '../graphql' +import {getFeatureTier} from './helpers/getFeatureTier' export const teamMeetingSettingsFields = () => ({ id: { @@ -29,7 +30,7 @@ export const teamMeetingSettingsFields = () => ({ {dataLoader}: GQLContext ) => { const team = await dataLoader.get('teams').loadNonNull(teamId) - return phaseTypes.filter(isPhaseAvailable(team.tier)) + return phaseTypes.filter(isPhaseAvailable(getFeatureTier(team))) } }, teamId: { diff --git a/packages/server/graphql/types/User.ts b/packages/server/graphql/types/User.ts index a2a1c4a2e13..c8a0cb1d2b4 100644 --- a/packages/server/graphql/types/User.ts +++ b/packages/server/graphql/types/User.ts @@ -44,7 +44,6 @@ import SuggestedAction from './SuggestedAction' import Team from './Team' import TeamInvitationPayload from './TeamInvitationPayload' import TeamMember from './TeamMember' -import TierEnum from './TierEnum' import {TimelineEventConnection} from './TimelineEvent' import TimelineEventTypeEnum from './TimelineEventTypeEnum' import TimelineEvent from '../../database/types/TimelineEvent' @@ -649,10 +648,6 @@ const User: GraphQLObjectType = new GraphQLObjectType { + if (tier === 'starter' && !!trialStartDate) { + return 'team' + } + return tier +} diff --git a/packages/server/graphql/types/helpers/isMeetingLocked.ts b/packages/server/graphql/types/helpers/isMeetingLocked.ts index 3a3e4b1166c..9ef490b93ec 100644 --- a/packages/server/graphql/types/helpers/isMeetingLocked.ts +++ b/packages/server/graphql/types/helpers/isMeetingLocked.ts @@ -1,4 +1,5 @@ import {DataLoaderWorker} from '../../graphql' +import {getFeatureTier} from './getFeatureTier' const isMeetingLocked = async ( viewerId: string, @@ -17,21 +18,20 @@ const isMeetingLocked = async ( ]) const {featureFlags} = viewer - const {tier, isPaid, orgId, isArchived} = team + const {tier, trialStartDate, isPaid, orgId, isArchived} = team if (featureFlags.includes('noMeetingHistoryLimit')) { return false } - if (tier !== 'starter' && isPaid) { + if ((tier !== 'starter' && isPaid) || trialStartDate) { return false } // Archived teams are not updated with the current tier, just check the organization if (isArchived) { const organization = await dataLoader.get('organizations').load(orgId) - const {tier} = organization - if (tier !== 'starter') { + if (getFeatureTier(organization) !== 'starter') { return false } } diff --git a/packages/server/postgres/migrations/1699394201062_trialColumns.ts b/packages/server/postgres/migrations/1699394201062_trialColumns.ts new file mode 100644 index 00000000000..303a6cf157f --- /dev/null +++ b/packages/server/postgres/migrations/1699394201062_trialColumns.ts @@ -0,0 +1,25 @@ +import {Client} from 'pg' +import getPgConfig from '../getPgConfig' + +export async function up() { + const client = new Client(getPgConfig()) + await client.connect() + await client.query(` + ALTER TABLE "User" + ADD COLUMN "trialStartDate" TIMESTAMP WITH TIME ZONE; + ALTER TABLE "Team" + ADD COLUMN "trialStartDate" TIMESTAMP WITH TIME ZONE; + `) + await client.end() +} + +export async function down() { + const client = new Client(getPgConfig()) + await client.connect() + await client.query(` + ALTER TABLE "User" + DROP COLUMN "trialStartDate"; + ALTER TABLE "Team" + DROP COLUMN "trialStartDate"`) + await client.end() +} diff --git a/packages/server/postgres/queries/src/updateUserTiersQuery.sql b/packages/server/postgres/queries/src/updateUserTiersQuery.sql index 181dc0f7b51..4f8bef44487 100644 --- a/packages/server/postgres/queries/src/updateUserTiersQuery.sql +++ b/packages/server/postgres/queries/src/updateUserTiersQuery.sql @@ -1,8 +1,9 @@ /* @name updateUserTiersQuery - @param users -> ((tier, id)...) + @param users -> ((tier, trialStartDate, id)...) */ UPDATE "User" AS u SET - "tier" = c."tier"::"TierEnum" -FROM (VALUES :users) AS c("tier", "id") -WHERE c."id" = u."id"; \ No newline at end of file + "tier" = c."tier"::"TierEnum", + "trialStartDate" = c."trialStartDate"::TIMESTAMP +FROM (VALUES :users) AS c("tier", "trialStartDate", "id") +WHERE c."id" = u."id"; diff --git a/packages/server/utils/analytics/analytics.ts b/packages/server/utils/analytics/analytics.ts index c0a5f073457..48f6ca17c41 100644 --- a/packages/server/utils/analytics/analytics.ts +++ b/packages/server/utils/analytics/analytics.ts @@ -54,6 +54,7 @@ export type OrgTierChangeEventProperties = { orgName: string oldTier: string newTier: string + isTrial?: boolean reasonsForLeaving?: ReasonToDowngradeEnum[] otherTool?: string billingLeaderEmail?: string diff --git a/packages/server/utils/setTierForOrgUsers.ts b/packages/server/utils/setTierForOrgUsers.ts index 17a089dbab8..3477e29b2ad 100644 --- a/packages/server/utils/setTierForOrgUsers.ts +++ b/packages/server/utils/setTierForOrgUsers.ts @@ -18,7 +18,12 @@ const setTierForOrgUsers = async (orgId: string) => { .filter({removedAt: null}) .update( { - tier: r.table('Organization').get(orgId).getField('tier') as unknown as TierEnum + tier: r.table('Organization').get(orgId).getField('tier') as unknown as TierEnum, + trialStartDate: r + .table('Organization') + .get(orgId) + .getField('trialStartDate') + .default(null) as unknown as Date | null }, {nonAtomic: true} ) diff --git a/packages/server/utils/setUserTierForUserIds.ts b/packages/server/utils/setUserTierForUserIds.ts index e9a00858c5a..880f2f23996 100644 --- a/packages/server/utils/setUserTierForUserIds.ts +++ b/packages/server/utils/setUserTierForUserIds.ts @@ -6,6 +6,9 @@ import {getUsersByIds} from '../postgres/queries/getUsersByIds' import updateUserTiers from '../postgres/queries/updateUserTiers' import {analytics} from './analytics/analytics' +// This doesn't actually read any tier/trial fields on the 'OrganizationUser' object - these fields +// come directly from 'Organization' instead. As a result, this can be run in parallel with +// 'setTierForOrgUsers'. const setUserTierForUserIds = async (userIds: string[]) => { const r = await getRethink() const userTiers = (await r @@ -13,29 +16,33 @@ const setUserTierForUserIds = async (userIds: string[]) => { .getAll(r.args(userIds), {index: 'userId'}) .filter({removedAt: null}) .merge((orgUser: RDatum) => ({ - tier: r.table('Organization').get(orgUser('orgId'))('tier').default('starter') + tier: r.table('Organization').get(orgUser('orgId'))('tier').default('starter'), + trialStartDate: r.table('Organization').get(orgUser('orgId'))('trialStartDate').default(null) })) - .group('userId')('tier') + .group('userId') .ungroup() .map((row) => ({ id: row('group'), tier: r.branch( - row('reduction').contains('enterprise'), + row('reduction')('tier').contains('enterprise'), 'enterprise', - row('reduction').contains('team'), + row('reduction')('tier').contains('team'), 'team', 'starter' - ) + ), + trialStartDate: r.max(row('reduction')('trialStartDate')) })) - .run()) as {id: string; tier: TierEnum}[] - const newUserTiers = userIds.map((userId) => { + .run()) as {id: string; tier: TierEnum; trialStartDate: string | null}[] + + const userUpdates = userIds.map((userId) => { const userTier = userTiers.find((userTier) => userTier.id === userId) return { id: userId, - tier: userTier ? userTier.tier : 'starter' + tier: userTier ? userTier.tier : 'starter', + trialStartDate: userTier ? userTier.trialStartDate : null } }) - await updateUserTiers({users: newUserTiers}) + await updateUserTiers({users: userUpdates}) const users = await getUsersByIds(userIds) users.forEach((user) => {