Skip to content

Commit

Permalink
Add Pricing Plans to Organization Page (#6602)
Browse files Browse the repository at this point in the history
* first draft for new organization page

* enable access to orga page in navbar avatar menu

* Add pricing plan schema

* fix navbar links to orga page

* more pricing stuff

* orga page refactoring

* Use default user/storage values for different plans

* Add new model fields

* Assert user count does not exceed includedUsers when joining org

* Pass incudedStorage in MB

* fix deprecation warning

* refactor orga view into sub-components

* adapt enum in scala

* many tweaks to orga view

* added modals for extending pricing plans

* added actual user count to orga page limits

* Insert new fields into organization table

* REVIEW fixed some TS errors: @ts-expect-error ts-migrate(2769) FIXME: No overload matches this call.

* enforce user quota for email invites

* map null value to max int

* handle infinite orga storage

* small re-phrasing

* enforce user limit on email invites

* pretty + lint

* Update conf/evolutions/091-pricing-plans.sql

Co-authored-by: Florian M <[email protected]>

* added background images for pricing plan modals

* added alert when plans is about to exceed

* show a plan expriation warning diectly on the dashboard

* fix content for upgrade plan modal

* prettier

* updated changelog

* adapt test db

* added backend routes for send out pricing plan uprgade emails

* backend formatting

* connect frontend and backend prciing email routes

* stuff

* Update frontend/javascripts/admin/admin_rest_api.ts

Co-authored-by: Philipp Otto <[email protected]>

* Update frontend/javascripts/admin/organization/upgrade_plan_modal.tsx

Co-authored-by: Philipp Otto <[email protected]>

* Update conf/messages

Co-authored-by: Philipp Otto <[email protected]>

* Update app/views/mail/upgradePricingPlanUsers.scala.html

Co-authored-by: Philipp Otto <[email protected]>

* Update app/views/mail/upgradePricingPlanToTeam.scala.html

Co-authored-by: Philipp Otto <[email protected]>

* Update app/views/mail/upgradePricingPlanToPower.scala.html

Co-authored-by: Philipp Otto <[email protected]>

* Update app/views/mail/upgradePricingPlanStorage.scala.html

Co-authored-by: Philipp Otto <[email protected]>

* Update app/views/mail/extendPricingPlan.scala.html

Co-authored-by: Philipp Otto <[email protected]>

* applied PR feedback and switch background images to JPEGs

* applied PR feedback #2

* PR feedback #3 / fix upgrade modals

* Update frontend/javascripts/admin/onboarding.tsx

Co-authored-by: Norman Rzepka <[email protected]>

* Update frontend/javascripts/admin/organization/organization_cards.tsx

Co-authored-by: Norman Rzepka <[email protected]>

* Update frontend/javascripts/admin/organization/pricing_plan_utils.ts

Co-authored-by: Norman Rzepka <[email protected]>

* Update frontend/javascripts/admin/organization/organization_cards.tsx

Co-authored-by: Norman Rzepka <[email protected]>

* Update frontend/javascripts/admin/organization/pricing_plan_utils.ts

Co-authored-by: Norman Rzepka <[email protected]>

* Update frontend/javascripts/admin/admin_rest_api.ts

Co-authored-by: Philipp Otto <[email protected]>

* PR feedback #4

* pretty

* PR feedback #5

* refactored pricing upgrade emails to be a confirmation to the user

* formatting

* fixed warning "plan is about to expire" when it already has expired

* fix evolution schema versioning

* rephrase all reference to "Free" plan to "Basic"

* Add route /pricing/status

* redesigned upgrade modal to show both team power plans

* integrated pricing plan status API

* fix evolutions

* also respect paidUntil in exceeded checks

* disable pricing plan warnings on dashboard for now

* make linter happy

* linting & formatting

* fix default orga DB

* fixed typescript typing errors

* Update frontend/javascripts/admin/organization/upgrade_plan_modal.tsx

Co-authored-by: Philipp Otto <[email protected]>

* applied PR feedback

* update schema version to 94

* fix CI?

* prevent prcing plan alarms from showing actions for unauthorized people

* added owner check for warnings

* added confirmation toasts on sucessful upgrade requests

* fix toast messages

* migration guide

Co-authored-by: frcroth <[email protected]>
Co-authored-by: Florian M <[email protected]>
Co-authored-by: Florian M <[email protected]>
Co-authored-by: Philipp Otto <[email protected]>
Co-authored-by: Norman Rzepka <[email protected]>
  • Loading branch information
6 people authored Jan 5, 2023
1 parent 4334bde commit 14dbb47
Show file tree
Hide file tree
Showing 46 changed files with 1,742 additions and 160 deletions.
12 changes: 12 additions & 0 deletions CHANGELOG.unreleased.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,19 @@ For upgrade instructions, please check the [migration guide](MIGRATIONS.released
### Added

### Changed
- The log viewer in the Voxelytics workflow reporting now uses a virtualized list. [#6579](https://github.com/scalableminds/webknossos/pull/6579)
- Node positions are always handled as integers. They have always been persisted as integers by the server, anyway, but the session in which a node was created handled the position as floating point in earlier versions. [#6589](https://github.com/scalableminds/webknossos/pull/6589)
- Jobs can no longer be started on datastores without workers. [#6595](https://github.com/scalableminds/webknossos/pull/6595)
- When downloading volume annotations with volume data skipped, the nml volume tag is now included anyway (but has no location attribute in this case). [#6566](https://github.com/scalableminds/webknossos/pull/6566)
- Re-phrased some backend (error) messages to improve clarity and provide helping hints. [#6616](https://github.com/scalableminds/webknossos/pull/6616)
- The layer visibility is now encoded in the sharing link. The user opening the link will see the same layers that were visible when copying the link. [#6634](https://github.com/scalableminds/webknossos/pull/6634)
- Voxelytics workflows can now be viewed by anyone with the link who is in the right organization. [#6622](https://github.com/scalableminds/webknossos/pull/6622)
- webKnossos is now able to recover from a lost webGL context. [#6663](https://github.com/scalableminds/webknossos/pull/6663)
- Bulk task creation now needs the taskTypeId, the task type summary will no longer be accepted. [#6640](https://github.com/scalableminds/webknossos/pull/6640)
- Error handling and reporting is more robust now. [#6700](https://github.com/scalableminds/webknossos/pull/6700)
- The Quick-Select settings are opened (and closed) automatically when labeling with the preview mode. That way, bulk labelings with preview mode don't require constantly opening the settings manually. [#6706](https://github.com/scalableminds/webknossos/pull/6706)
- Improved performance of opening a dataset or annotation. [#6711](https://github.com/scalableminds/webknossos/pull/6711)
- Redesigned organization page to include more infos on organization users, storage, webKnossos plan and provided opportunities to upgrade. [#6602](https://github.com/scalableminds/webknossos/pull/6602)

### Fixed
- Fixed the validation of some neuroglancer URLs during import. [#6722](https://github.com/scalableminds/webknossos/pull/6722)
Expand Down
2 changes: 2 additions & 0 deletions MIGRATIONS.unreleased.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,3 +9,5 @@ User-facing changes are documented in the [changelog](CHANGELOG.released.md).
[Commits](https://github.com/scalableminds/webknossos/compare/23.01.0...HEAD)

### Postgres Evolutions:

- [094-pricing-plans.sql](conf/evolutions/reversions/094-pricing-plans.sql)
6 changes: 6 additions & 0 deletions app/controllers/AuthenticationController.scala
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,8 @@ class AuthenticationController @Inject()(
organization <- organizationService.findOneByInviteByNameOrDefault(
inviteBox.toOption,
organizationName)(GlobalAccessContext) ?~> Messages("organization.notFound", signUpData.organization)
_ <- organizationService
.assertUsersCanBeAdded(organization)(GlobalAccessContext, ec) ?~> "organization.users.userLimitReached"
autoActivate = inviteBox.toOption.map(_.autoActivate).getOrElse(organization.enableAutoVerify)
_ <- createUser(organization,
email,
Expand Down Expand Up @@ -338,6 +340,10 @@ class AuthenticationController @Inject()(
invite <- inviteDAO.findOneByTokenValue(inviteToken) ?~> "invite.invalidToken"
organization <- organizationDAO.findOne(invite._organization)(GlobalAccessContext) ?~> "invite.invalidToken"
_ <- userService.assertNotInOrgaYet(request.identity._multiUser, organization._id)
requestingMultiUser <- multiUserDAO.findOne(request.identity._multiUser)
_ <- Fox.runIf(!requestingMultiUser.isSuperUser)(
organizationService
.assertUsersCanBeAdded(organization)(GlobalAccessContext, ec)) ?~> "organization.users.userLimitReached"
_ <- userService.joinOrganization(request.identity, organization._id, autoActivate = invite.autoActivate)
_ = analyticsService.track(JoinOrganizationEvent(request.identity, organization))
userEmail <- userService.emailFor(request.identity)
Expand Down
19 changes: 12 additions & 7 deletions app/controllers/InitialDataController.scala
Original file line number Diff line number Diff line change
Expand Up @@ -67,13 +67,18 @@ Samplecountry
"""
private val organizationTeamId = ObjectId.generate
private val defaultOrganization =
Organization(ObjectId.generate,
"sample_organization",
additionalInformation,
"/assets/images/oxalis.svg",
"Sample Organization",
PricingPlan.Custom,
ObjectId.generate)
Organization(
ObjectId.generate,
"sample_organization",
additionalInformation,
"/assets/images/oxalis.svg",
"Sample Organization",
PricingPlan.Custom,
None,
None,
None,
ObjectId.generate
)
private val organizationTeam =
Team(organizationTeamId, defaultOrganization._id, "Default", isOrganizationTeam = true)
private val userId = ObjectId.generate
Expand Down
99 changes: 97 additions & 2 deletions app/controllers/OrganizationController.scala
Original file line number Diff line number Diff line change
@@ -1,17 +1,21 @@
package controllers

import akka.actor.ActorSystem
import com.mohiva.play.silhouette.api.Silhouette
import com.scalableminds.util.accesscontext.{DBAccessContext, GlobalAccessContext}
import com.scalableminds.util.tools.{Fox, FoxImplicits}
import javax.inject.Inject
import models.organization.{OrganizationDAO, OrganizationService}
import models.user.{InviteDAO, MultiUserDAO, UserDAO}
import models.user.{InviteDAO, MultiUserDAO, UserDAO, UserService}
import models.team.{PricingPlan}
import oxalis.security.{WkEnv, WkSilhouetteEnvironment}
import play.api.i18n.Messages
import play.api.libs.functional.syntax._
import play.api.libs.json.{JsNull, JsValue, Json, __}
import play.api.mvc.{Action, AnyContent}
import utils.WkConf
import scala.concurrent.duration._
import oxalis.mail.{DefaultMails, Send}

import scala.concurrent.ExecutionContext

Expand All @@ -22,11 +26,15 @@ class OrganizationController @Inject()(organizationDAO: OrganizationDAO,
userDAO: UserDAO,
multiUserDAO: MultiUserDAO,
wkSilhouetteEnvironment: WkSilhouetteEnvironment,
userService: UserService,
defaultMails: DefaultMails,
actorSystem: ActorSystem,
sil: Silhouette[WkEnv])(implicit ec: ExecutionContext)
extends Controller
with FoxImplicits {

private val combinedAuthenticatorService = wkSilhouetteEnvironment.combinedAuthenticatorService
private lazy val Mailer = actorSystem.actorSelection("/user/mailActor")

def organizationsIsEmpty: Action[AnyContent] = Action.async { implicit request =>
for {
Expand Down Expand Up @@ -134,7 +142,7 @@ class OrganizationController @Inject()(organizationDAO: OrganizationDAO,
organization <- organizationDAO.findOneByName(organizationName) ?~> Messages("organization.notFound",
organizationName) ~> NOT_FOUND
_ <- bool2Fox(request.identity.isAdminOf(organization._id)) ?~> "notAllowed" ~> FORBIDDEN
_ = logger.info(s"Deleting organizaion ${organization._id}")
_ = logger.info(s"Deleting organization ${organization._id}")
_ <- organizationDAO.deleteOne(organization._id)
_ <- userDAO.deleteAllWithOrganization(organization._id)
_ <- multiUserDAO.removeLastLoggedInIdentitiesWithOrga(organization._id)
Expand All @@ -146,4 +154,91 @@ class OrganizationController @Inject()(organizationDAO: OrganizationDAO,
((__ \ 'displayName).read[String] and
(__ \ 'newUserMailingList).read[String]).tupled

def sendExtendPricingPlanEmail(): Action[AnyContent] = sil.SecuredAction.async { implicit request =>
for {
_ <- bool2Fox(request.identity.isAdmin) ?~> Messages("organization.pricingUpgrades.notAuthorized")
organization <- organizationDAO
.findOne(request.identity._organization) ?~> Messages("organization.notFound") ~> NOT_FOUND
userEmail <- userService.emailFor(request.identity)
_ = Mailer ! Send(defaultMails.extendPricingPlanMail(request.identity, userEmail))
_ = Mailer ! Send(
defaultMails.upgradePricingPlanRequestMail(request.identity,
userEmail,
organization.displayName,
"Extend webKnossos plan by a year"))
} yield Ok
}

def sendUpgradePricingPlanEmail(requestedPlan: String): Action[AnyContent] = sil.SecuredAction.async {
implicit request =>
for {
_ <- bool2Fox(request.identity.isAdmin) ?~> Messages("organization.pricingUpgrades.notAuthorized")
organization <- organizationDAO
.findOne(request.identity._organization) ?~> Messages("organization.notFound") ~> NOT_FOUND
userEmail <- userService.emailFor(request.identity)
requestedPlan <- PricingPlan.fromString(requestedPlan)
mail = if (requestedPlan == PricingPlan.Team) {
defaultMails.upgradePricingPlanToTeamMail _
} else {
defaultMails.upgradePricingPlanToTeamMail _
}
_ = Mailer ! Send(mail(request.identity, userEmail))
_ = Mailer ! Send(
defaultMails.upgradePricingPlanRequestMail(request.identity,
userEmail,
organization.displayName,
s"Upgrade webKnossos Plan to $requestedPlan"))
} yield Ok
}

def sendUpgradePricingPlanUsersEmail(requestedUsers: Int): Action[AnyContent] =
sil.SecuredAction.async { implicit request =>
for {
_ <- bool2Fox(request.identity.isAdmin) ?~> Messages("organization.pricingUpgrades.notAuthorized")
organization <- organizationDAO.findOne(request.identity._organization) ?~> Messages("organization.notFound") ~> NOT_FOUND
userEmail <- userService.emailFor(request.identity)
_ = Mailer ! Send(defaultMails.upgradePricingPlanUsersMail(request.identity, userEmail, requestedUsers))
_ = Mailer ! Send(
defaultMails.upgradePricingPlanRequestMail(request.identity,
userEmail,
organization.displayName,
s"Purchase $requestedUsers additional users"))
} yield Ok
}

def sendUpgradePricingPlanStorageEmail(requestedStorage: Int): Action[AnyContent] =
sil.SecuredAction.async { implicit request =>
for {
_ <- bool2Fox(request.identity.isAdmin) ?~> Messages("organization.pricingUpgrades.notAuthorized")
organization <- organizationDAO.findOne(request.identity._organization) ?~> Messages("organization.notFound") ~> NOT_FOUND
userEmail <- userService.emailFor(request.identity)
_ = Mailer ! Send(defaultMails.upgradePricingPlanStorageMail(request.identity, userEmail, requestedStorage))
_ = Mailer ! Send(
defaultMails.upgradePricingPlanRequestMail(request.identity,
userEmail,
organization.displayName,
s"Purchase $requestedStorage TB additional storage"))
} yield Ok
}

def pricingStatus: Action[AnyContent] =
sil.SecuredAction.async { implicit request =>
for {
organization <- organizationDAO.findOne(request.identity._organization)
activeUserCount <- userDAO.countAllForOrganization(request.identity._organization)
// Note that this does not yet account for storage
isExceeded = organization.includedUsers.exists(userLimit => activeUserCount > userLimit) || organization.paidUntil
.exists(_.isPast)
isAlmostExceeded = (activeUserCount > 1 && organization.includedUsers.exists(userLimit =>
activeUserCount > userLimit - 2)) || organization.paidUntil.exists(paidUntil =>
(paidUntil - (6 * 7 days)).isPast)
} yield
Ok(
Json.obj(
"pricingPlan" -> organization.pricingPlan,
"isExceeded" -> isExceeded,
"isAlmostExceeded" -> isAlmostExceeded
))
}

}
27 changes: 15 additions & 12 deletions app/models/organization/Organization.scala
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,9 @@ case class Organization(
logoUrl: String,
displayName: String,
pricingPlan: PricingPlan,
paidUntil: Option[Instant],
includedUsers: Option[Int], // None means unlimited
includedStorage: Option[Long], // None means unlimited
_rootFolder: ObjectId,
newUserMailingList: String = "",
overTimeMailingList: String = "",
Expand Down Expand Up @@ -51,6 +54,9 @@ class OrganizationDAO @Inject()(sqlClient: SQLClient)(implicit ec: ExecutionCont
r.logourl,
r.displayname,
pricingPlan,
r.paiduntil.map(Instant.fromSql),
r.includedusers,
r.includedstorage,
ObjectId(r._Rootfolder),
r.newusermailinglist,
r.overtimemailinglist,
Expand Down Expand Up @@ -88,18 +94,15 @@ class OrganizationDAO @Inject()(sqlClient: SQLClient)(implicit ec: ExecutionCont

def insertOne(o: Organization): Fox[Unit] =
for {
_ <- run(sqlu"""INSERT INTO webknossos.organizations(
_id, name, additionalInformation, logoUrl, displayName, _rootFolder, newUserMailingList, overTimeMailingList,
enableAutoVerify, lastTermsOfServiceAcceptanceTime, lastTermsOfServiceAcceptanceVersion,
created, isDeleted
)
VALUES(
${o._id}, ${o.name}, ${o.additionalInformation}, ${o.logoUrl}, ${o.displayName},
${o._rootFolder}, ${o.newUserMailingList}, ${o.overTimeMailingList}, ${o.enableAutoVerify},
${o.lastTermsOfServiceAcceptanceTime},
${o.lastTermsOfServiceAcceptanceVersion},
${o.created}, ${o.isDeleted}
)
_ <- run(sqlu"""INSERT INTO webknossos.organizations
(_id, name, additionalInformation, logoUrl, displayName, _rootFolder,
newUserMailingList, overTimeMailingList, enableAutoVerify,
pricingplan, paidUntil, includedusers, includedstorage, lastTermsOfServiceAcceptanceTime, lastTermsOfServiceAcceptanceVersion, created, isDeleted)
VALUES
(${o._id.id}, ${o.name}, ${o.additionalInformation}, ${o.logoUrl}, ${o.displayName}, ${o._rootFolder},
${o.newUserMailingList}, ${o.overTimeMailingList}, ${o.enableAutoVerify},
'#${o.pricingPlan}', ${o.paidUntil}, ${o.includedUsers}, ${o.includedStorage}, ${o.lastTermsOfServiceAcceptanceTime},
${o.lastTermsOfServiceAcceptanceVersion}, ${o.created}, ${o.isDeleted})
""")
} yield ()

Expand Down
42 changes: 31 additions & 11 deletions app/models/organization/OrganizationService.scala
Original file line number Diff line number Diff line change
Expand Up @@ -9,14 +9,15 @@ import javax.inject.Inject
import models.binary.{DataStore, DataStoreDAO}
import models.folder.{Folder, FolderDAO, FolderService}
import models.team.{PricingPlan, Team, TeamDAO}
import models.user.{Invite, MultiUserDAO, User}
import models.user.{Invite, MultiUserDAO, User, UserDAO}
import play.api.libs.json.{JsObject, Json}
import utils.{ObjectId, WkConf}

import scala.concurrent.{ExecutionContext, Future}

class OrganizationService @Inject()(organizationDAO: OrganizationDAO,
multiUserDAO: MultiUserDAO,
userDAO: UserDAO,
teamDAO: TeamDAO,
dataStoreDAO: DataStoreDAO,
folderDAO: FolderDAO,
Expand All @@ -31,7 +32,6 @@ class OrganizationService @Inject()(organizationDAO: OrganizationDAO,
val adminOnlyInfo = if (requestingUser.exists(_.isAdminOf(organization._id))) {
Json.obj(
"newUserMailingList" -> organization.newUserMailingList,
"pricingPlan" -> organization.pricingPlan,
"lastTermsOfServiceAcceptanceTime" -> organization.lastTermsOfServiceAcceptanceTime,
"lastTermsOfServiceAcceptanceVersion" -> organization.lastTermsOfServiceAcceptanceVersion
)
Expand All @@ -42,7 +42,11 @@ class OrganizationService @Inject()(organizationDAO: OrganizationDAO,
"name" -> organization.name,
"additionalInformation" -> organization.additionalInformation,
"enableAutoVerify" -> organization.enableAutoVerify,
"displayName" -> organization.displayName
"displayName" -> organization.displayName,
"pricingPlan" -> organization.pricingPlan,
"paidUntil" -> organization.paidUntil,
"includedUsers" -> organization.includedUsers,
"includedStorage" -> organization.includedStorage.map(bytes => bytes / 1000000)
) ++ adminOnlyInfo
)
}
Expand Down Expand Up @@ -82,15 +86,22 @@ class OrganizationService @Inject()(organizationDAO: OrganizationDAO,
.replaceAll(" ", "_")
existingOrganization <- organizationDAO.findOneByName(organizationName)(GlobalAccessContext).futureBox
_ <- bool2Fox(existingOrganization.isEmpty) ?~> "organization.name.alreadyInUse"
initialPricingPlan = if (conf.Features.isDemoInstance) PricingPlan.Basic else PricingPlan.Custom
initialPricingParameters = if (conf.Features.isDemoInstance) (PricingPlan.Basic, Some(3), Some(50000000000L))
else (PricingPlan.Custom, None, None)
organizationRootFolder = Folder(ObjectId.generate, folderService.defaultRootName)
organization = Organization(ObjectId.generate,
organizationName,
"",
"",
organizationDisplayName,
initialPricingPlan,
organizationRootFolder._id)

organization = Organization(
ObjectId.generate,
organizationName,
"",
"",
organizationDisplayName,
initialPricingParameters._1,
None,
initialPricingParameters._2,
initialPricingParameters._3,
organizationRootFolder._id
)
organizationTeam = Team(ObjectId.generate, organization._id, "Default", isOrganizationTeam = true)
_ <- folderDAO.insertAsRoot(organizationRootFolder)
_ <- organizationDAO.insertOne(organization)
Expand All @@ -111,4 +122,13 @@ class OrganizationService @Inject()(organizationDAO: OrganizationDAO,
} yield ()
}

def assertUsersCanBeAdded(organization: Organization, usersToAddCount: Int = 1)(implicit ctx: DBAccessContext,
ec: ExecutionContext): Fox[Unit] =
for {
_ <- organizationDAO.findOne(organization._id)
userCount <- userDAO.countAllForOrganization(organization._id)
_ <- Fox.runOptional(organization.includedUsers)(includedUsers =>
bool2Fox(userCount + usersToAddCount <= includedUsers))
} yield ()

}
2 changes: 1 addition & 1 deletion app/models/team/PricingPlan.scala
Original file line number Diff line number Diff line change
Expand Up @@ -4,5 +4,5 @@ import com.scalableminds.util.enumeration.ExtendedEnumeration

object PricingPlan extends ExtendedEnumeration {
type PricingPlan = Value
val Basic, Premium, Pilot, Custom = Value
val Basic, Team, Power, Team_Trial, Power_Trial, Custom = Value
}
Loading

0 comments on commit 14dbb47

Please sign in to comment.