Skip to content

Commit

Permalink
MM-44112 - Upsell flow for cloud (#78)
Browse files Browse the repository at this point in the history
* pricing flow and modals, notify admins

* go mod tidy

* merge conflicts

* server-side PR comments

* edge case for cloud sku

* UI PR comments

* go mod tidy

* go mod tidy

* Update server/cloud_limits.go

Co-authored-by: Justine Geffen <[email protected]>

* Update server/cloud_limits.go

Co-authored-by: Justine Geffen <[email protected]>

* Update webapp/src/cloud_pricing/modals.tsx

Co-authored-by: Justine Geffen <[email protected]>

* Update webapp/src/cloud_pricing/modals.tsx

Co-authored-by: Justine Geffen <[email protected]>

* Update webapp/src/cloud_pricing/modals.tsx

Co-authored-by: Justine Geffen <[email protected]>

* Update webapp/src/cloud_pricing/modals.tsx

Co-authored-by: Justine Geffen <[email protected]>

* Update webapp/src/cloud_pricing/modals.tsx

Co-authored-by: Justine Geffen <[email protected]>

* Update webapp/src/components/channel_header_button/component.tsx

Co-authored-by: Justine Geffen <[email protected]>

* Update webapp/src/components/channel_header_button/component.tsx

Co-authored-by: Justine Geffen <[email protected]>

* Update webapp/src/components/channel_header_button/component.tsx

Co-authored-by: Justine Geffen <[email protected]>

* Update webapp/src/components/channel_header_button/component.tsx

Co-authored-by: Justine Geffen <[email protected]>

* Update webapp/src/components/channel_header_dropdown_button/component.tsx

Co-authored-by: Justine Geffen <[email protected]>

* Update webapp/src/components/channel_header_dropdown_button/component.tsx

Co-authored-by: Justine Geffen <[email protected]>

* Update webapp/src/components/channel_header_dropdown_button/component.tsx

Co-authored-by: Justine Geffen <[email protected]>

* copy change

* copy change

Co-authored-by: Justine Geffen <[email protected]>
  • Loading branch information
cpoile and justinegeffen authored May 24, 2022
1 parent 0f39e23 commit fb89a5a
Show file tree
Hide file tree
Showing 26 changed files with 1,714 additions and 60 deletions.
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ require (
github.com/mattermost/logr/v2 v2.0.15
github.com/mattermost/mattermost-plugin-api v0.0.27
github.com/mattermost/rtcd v0.5.1
github.com/pkg/errors v0.9.1
github.com/rudderlabs/analytics-go v3.3.2+incompatible
golang.org/x/time v0.0.0-20201208040808-7e3f01d25324
)
Expand Down Expand Up @@ -76,7 +77,6 @@ require (
github.com/pion/transport v0.13.0 // indirect
github.com/pion/turn/v2 v2.0.8 // indirect
github.com/pion/udp v0.1.1 // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/plar/go-adaptive-radix-tree v1.0.4 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/prometheus/client_model v0.2.0 // indirect
Expand Down
12 changes: 11 additions & 1 deletion server/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -318,7 +318,9 @@ func (p *Plugin) ServeHTTP(c *plugin.Context, w http.ResponseWriter, r *http.Req

// Return license information that isn't exposed to clients yet
if r.URL.Path == "/cloud-info" {
p.handleCloudInfo(w)
if err := p.handleCloudInfo(w); err != nil {
p.handleError(w, err)
}
return
}

Expand All @@ -334,6 +336,14 @@ func (p *Plugin) ServeHTTP(c *plugin.Context, w http.ResponseWriter, r *http.Req
}

if r.Method == http.MethodPost {
// End user has requested to notify their admin about upgrading for calls
if r.URL.Path == "/cloud-notify-admins" {
if err := p.handleCloudNotifyAdmins(w, r); err != nil {
p.handleError(w, err)
}
return
}

if matches := chRE.FindStringSubmatch(r.URL.Path); len(matches) == 2 {
p.handlePostChannel(w, r, matches[1])
return
Expand Down
104 changes: 99 additions & 5 deletions server/cloud_limits.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,18 @@ package main

import (
"encoding/json"
"fmt"
"github.com/mattermost/mattermost-server/v6/model"
"github.com/pkg/errors"
"net/http"
)

// cloudMaxParticipants defaults to 8, can be overridden by setting the env variable
// MM_CALLS_CLOUD_MAX_PARTICIPANTS
var cloudMaxParticipants = 8

const maxAdminsToQueryForNotification = 25

// JoinAllowed returns true if the user is allowed to join the call, taking into
// account cloud limits
func (p *Plugin) joinAllowed(channel *model.Channel, state *channelState) (bool, error) {
Expand All @@ -36,23 +40,113 @@ func (p *Plugin) joinAllowed(channel *model.Channel, state *channelState) (bool,
}

// handleCloudInfo returns license information that isn't exposed to clients yet
func (p *Plugin) handleCloudInfo(w http.ResponseWriter) {
func (p *Plugin) handleCloudInfo(w http.ResponseWriter) error {
license := p.pluginAPI.System.GetLicense()
if license == nil {
http.Error(w, "no license", http.StatusBadRequest)
return
p.handleErrorWithCode(w, http.StatusBadRequest, "no license",
errors.New("no license found"))
return nil
}

w.Header().Set("Content-Type", "application/json")
info := map[string]interface{}{
"sku_short_name": license.SkuShortName,
}
if err := json.NewEncoder(w).Encode(info); err != nil {
p.LogError(err.Error())
http.Error(w, "error encoding, see internal logs", http.StatusInternalServerError)
return errors.Wrap(err, "error encoding cloud info")
}

return nil
}

// handleCloudNotifyAdmins notifies the user's admin about upgrading for calls
func (p *Plugin) handleCloudNotifyAdmins(w http.ResponseWriter, r *http.Request) error {
license := p.pluginAPI.System.GetLicense()
if !isCloud(license) {
p.handleErrorWithCode(w, http.StatusBadRequest, "not a cloud server",
errors.New("not a cloud server, will not notify admins"))
return nil
}

userID := r.Header.Get("Mattermost-User-Id")

author, err := p.pluginAPI.User.Get(userID)
if err != nil {
return errors.Wrap(err, "unable to find author user")
}

admins, err := p.pluginAPI.User.List(&model.UserGetOptions{
Role: model.SystemAdminRoleId,
Page: 0,
PerPage: maxAdminsToQueryForNotification,
})

if err != nil {
return errors.Wrap(err, "unable to find all admin users")
}

if len(admins) == 0 {
return fmt.Errorf("no admins found")
}

separator := "\n\n---\n\n"
postType := "custom_cloud_trial_req"
message := fmt.Sprintf("@%s requested access to a free trial for Calls.", author.Username)
title := "Make calls in channels"
text := "Start a call in a channel. You can include up to 8 participants per call." + separator + "[Upgrade now](https://customers.mattermost.com)."

attachments := []*model.SlackAttachment{
{
Title: title,
Text: separator + text,
},
}

systemBotID, err := p.getSystemBotID()
if err != nil {
return err
}

for _, admin := range admins {
channel, err := p.pluginAPI.Channel.GetDirect(admin.Id, systemBotID)
if err != nil {
p.pluginAPI.Log.Warn("failed to get Direct Message channel between user and bot", "user ID", admin.Id, "bot ID", systemBotID, "error", err)
continue
}

post := &model.Post{
Message: message,
UserId: systemBotID,
ChannelId: channel.Id,
Type: postType,
}
model.ParseSlackAttachment(post, attachments)
if err := p.pluginAPI.Post.CreatePost(post); err != nil {
p.pluginAPI.Log.Warn("failed to send a DM to user", "user ID", admin.Id, "error", err)
}
}

p.track(evCallNotifyAdmin, map[string]interface{}{
"ActualUserID": userID,
"MessageType": postType,
})

w.WriteHeader(http.StatusOK)
return nil
}

func (p *Plugin) getSystemBotID() (string, error) {
botID, err := p.pluginAPI.Bot.EnsureBot(&model.Bot{
Username: model.BotSystemBotUsername,
DisplayName: "System",
})

if err != nil {
return "", errors.New("failed to ensure system bot")
}

return botID, nil
}
func isCloud(license *model.License) bool {
if license == nil || license.Features == nil || license.Features.Cloud == nil {
return false
Expand Down
32 changes: 32 additions & 0 deletions server/error_handler.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
package main

import (
"encoding/json"
"fmt"
"net/http"
)

func (p *Plugin) handleError(w http.ResponseWriter, internalErr error) {
p.handleErrorWithCode(w, http.StatusInternalServerError, "An internal error has occurred. Check app server logs for details.", internalErr)
}

// handleErrorWithCode logs the internal error and sends the public facing error
// message as JSON in a response with the provided code.
func (p *Plugin) handleErrorWithCode(w http.ResponseWriter, code int, publicErrorMsg string, internalErr error) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(code)

details := ""
if internalErr != nil {
details = internalErr.Error()
}

p.LogError(fmt.Sprintf("public error message: %v; internal details: %v", publicErrorMsg, details))

responseMsg, _ := json.Marshal(struct {
Error string `json:"error"` // A public facing message providing details about the error.
}{
Error: publicErrorMsg,
})
_, _ = w.Write(responseMsg)
}
9 changes: 5 additions & 4 deletions server/telemetry.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,11 @@ import (
)

const (
evCallStarted = "call_started"
evCallEnded = "call_ended"
evCallUserJoined = "call_user_joined"
evCallUserLeft = "call_user_left"
evCallStarted = "call_started"
evCallEnded = "call_ended"
evCallUserJoined = "call_user_joined"
evCallUserLeft = "call_user_left"
evCallNotifyAdmin = "call_notify_admin"
)

func (p *Plugin) track(ev string, props map[string]interface{}) {
Expand Down
91 changes: 90 additions & 1 deletion webapp/src/actions.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,28 @@
import {Dispatch} from 'redux';
import {ActionFunc, GenericAction} from 'mattermost-redux/types/actions';
import {ActionFunc, DispatchFunc, GenericAction, GetStateFunc} from 'mattermost-redux/types/actions';

import {bindClientFunc} from 'mattermost-redux/actions/helpers';

import {Client4} from 'mattermost-redux/client';

import {CloudCustomer} from '@mattermost/types/cloud';

import {isCurrentUserSystemAdmin} from 'mattermost-redux/selectors/entities/users';

import {CloudInfo} from 'src/types/types';
import {getPluginPath} from 'src/utils';

import {modals, openPricingModal} from 'src/webapp_globals';
import {
CloudFreeTrialErrorModal,
CloudFreeTrialModalAdmin,
CloudFreeTrialModalUser,
CloudFreeTrialSuccessModal,
IDAdmin, IDError,
IDSuccess,
IDUser,
} from 'src/cloud_pricing/modals';

import {
SHOW_EXPANDED_VIEW,
HIDE_EXPANDED_VIEW,
Expand Down Expand Up @@ -66,3 +81,77 @@ export const getCloudInfo = (): ActionFunc => {
onSuccess: [RECEIVED_CLOUD_INFO],
});
};

export const notifyAdminCloudFreeTrial = async () => {
return Client4.doFetch(
`${getPluginPath()}/cloud-notify-admins`,
{method: 'post'},
);
};

export const requestCloudTrial = async () => {
try {
await Client4.doFetchWithResponse<CloudCustomer>(
`${Client4.getCloudRoute()}/request-trial`,
{method: 'put'},
);
} catch (error) {
return false;
}
return true;
};

export const displayFreeTrial = () => {
return async (dispatch: DispatchFunc, getState: GetStateFunc) => {
const isAdmin = isCurrentUserSystemAdmin(getState());

if (isAdmin) {
dispatch(modals.openModal({
modalId: IDAdmin,
dialogType: CloudFreeTrialModalAdmin,
}));
} else {
dispatch(modals.openModal({
modalId: IDUser,
dialogType: CloudFreeTrialModalUser,
}));
}

return {};
};
};

export const displayCloudPricing = () => {
return async (dispatch: DispatchFunc, getState: GetStateFunc) => {
const isAdmin = isCurrentUserSystemAdmin(getState());
if (!isAdmin) {
return {};
}

openPricingModal()();
return {};
};
};

export const requestTrial = () => {
return async (dispatch: DispatchFunc, getState: GetStateFunc) => {
const isAdmin = isCurrentUserSystemAdmin(getState());
if (!isAdmin) {
return {};
}

const success = await requestCloudTrial();
if (success) {
dispatch(modals.openModal({
modalId: IDSuccess,
dialogType: CloudFreeTrialSuccessModal,
}));
} else {
dispatch(modals.openModal({
modalId: IDError,
dialogType: CloudFreeTrialErrorModal,
}));
}
return {};
};
};
Loading

0 comments on commit fb89a5a

Please sign in to comment.