Per user per creator changes

This commit is contained in:
jvcalderon 2023-10-18 13:36:34 +02:00
parent 9a8f7aeb84
commit 5b2f55a592
17 changed files with 237 additions and 56 deletions

View File

@ -119,8 +119,8 @@ export class Writethrough {
this.writeRateMs = writeRateMs this.writeRateMs = writeRateMs
} }
async put(doc: any) { async put(doc: any, writeRateMs: number = this.writeRateMs) {
return put(this.db, doc, this.writeRateMs) return put(this.db, doc, writeRateMs)
} }
async get(id: string) { async get(id: string) {

View File

@ -21,17 +21,21 @@ import {
User, User,
UserStatus, UserStatus,
UserGroup, UserGroup,
ContextUser,
} from "@budibase/types" } from "@budibase/types"
import { import {
getAccountHolderFromUserIds, getAccountHolderFromUserIds,
isAdmin, isAdmin,
isCreator,
validateUniqueUser, validateUniqueUser,
} from "./utils" } from "./utils"
import { searchExistingEmails } from "./lookup" import { searchExistingEmails } from "./lookup"
import { hash } from "../utils" import { hash } from "../utils"
type QuotaUpdateFn = (change: number, cb?: () => Promise<any>) => Promise<any> type QuotaUpdateFn = (
change: number,
creatorsChange: number,
cb?: () => Promise<any>
) => Promise<any>
type GroupUpdateFn = (groupId: string, userIds: string[]) => Promise<any> type GroupUpdateFn = (groupId: string, userIds: string[]) => Promise<any>
type FeatureFn = () => Promise<Boolean> type FeatureFn = () => Promise<Boolean>
type GroupGetFn = (ids: string[]) => Promise<UserGroup[]> type GroupGetFn = (ids: string[]) => Promise<UserGroup[]>
@ -135,7 +139,7 @@ export class UserDB {
if (!fullUser.roles) { if (!fullUser.roles) {
fullUser.roles = {} fullUser.roles = {}
} }
// add the active status to a user if its not provided // add the active status to a user if it's not provided
if (fullUser.status == null) { if (fullUser.status == null) {
fullUser.status = UserStatus.ACTIVE fullUser.status = UserStatus.ACTIVE
} }
@ -246,7 +250,8 @@ export class UserDB {
} }
const change = dbUser ? 0 : 1 // no change if there is existing user const change = dbUser ? 0 : 1 // no change if there is existing user
return UserDB.quotas.addUsers(change, async () => { const creatorsChange = isCreator(dbUser) !== isCreator(user) ? 1 : 0
return UserDB.quotas.addUsers(change, creatorsChange, async () => {
await validateUniqueUser(email, tenantId) await validateUniqueUser(email, tenantId)
let builtUser = await UserDB.buildUser(user, opts, tenantId, dbUser) let builtUser = await UserDB.buildUser(user, opts, tenantId, dbUser)
@ -308,6 +313,7 @@ export class UserDB {
let usersToSave: any[] = [] let usersToSave: any[] = []
let newUsers: any[] = [] let newUsers: any[] = []
let newCreators: any[] = []
const emails = newUsersRequested.map((user: User) => user.email) const emails = newUsersRequested.map((user: User) => user.email)
const existingEmails = await searchExistingEmails(emails) const existingEmails = await searchExistingEmails(emails)
@ -328,59 +334,66 @@ export class UserDB {
} }
newUser.userGroups = groups newUser.userGroups = groups
newUsers.push(newUser) newUsers.push(newUser)
if (isCreator(newUser)) {
newCreators.push(newUser)
}
} }
const account = await accountSdk.getAccountByTenantId(tenantId) const account = await accountSdk.getAccountByTenantId(tenantId)
return UserDB.quotas.addUsers(newUsers.length, async () => { return UserDB.quotas.addUsers(
// create the promises array that will be called by bulkDocs newUsers.length,
newUsers.forEach((user: any) => { newCreators.length,
usersToSave.push( async () => {
UserDB.buildUser( // create the promises array that will be called by bulkDocs
user, newUsers.forEach((user: any) => {
{ usersToSave.push(
hashPassword: true, UserDB.buildUser(
requirePassword: user.requirePassword, user,
}, {
tenantId, hashPassword: true,
undefined, // no dbUser requirePassword: user.requirePassword,
account },
tenantId,
undefined, // no dbUser
account
)
) )
) })
})
const usersToBulkSave = await Promise.all(usersToSave) const usersToBulkSave = await Promise.all(usersToSave)
await usersCore.bulkUpdateGlobalUsers(usersToBulkSave) await usersCore.bulkUpdateGlobalUsers(usersToBulkSave)
// Post-processing of bulk added users, e.g. events and cache operations // Post-processing of bulk added users, e.g. events and cache operations
for (const user of usersToBulkSave) { for (const user of usersToBulkSave) {
// TODO: Refactor to bulk insert users into the info db // TODO: Refactor to bulk insert users into the info db
// instead of relying on looping tenant creation // instead of relying on looping tenant creation
await platform.users.addUser(tenantId, user._id, user.email) await platform.users.addUser(tenantId, user._id, user.email)
await eventHelpers.handleSaveEvents(user, undefined) await eventHelpers.handleSaveEvents(user, undefined)
} }
const saved = usersToBulkSave.map(user => {
return {
_id: user._id,
email: user.email,
}
})
// now update the groups
if (Array.isArray(saved) && groups) {
const groupPromises = []
const createdUserIds = saved.map(user => user._id)
for (let groupId of groups) {
groupPromises.push(UserDB.groups.addUsers(groupId, createdUserIds))
}
await Promise.all(groupPromises)
}
const saved = usersToBulkSave.map(user => {
return { return {
_id: user._id, successful: saved,
email: user.email, unsuccessful,
} }
})
// now update the groups
if (Array.isArray(saved) && groups) {
const groupPromises = []
const createdUserIds = saved.map(user => user._id)
for (let groupId of groups) {
groupPromises.push(UserDB.groups.addUsers(groupId, createdUserIds))
}
await Promise.all(groupPromises)
} }
)
return {
successful: saved,
unsuccessful,
}
})
} }
static async bulkDelete(userIds: string[]): Promise<BulkUserDeleted> { static async bulkDelete(userIds: string[]): Promise<BulkUserDeleted> {
@ -420,11 +433,12 @@ export class UserDB {
_deleted: true, _deleted: true,
})) }))
const dbResponse = await usersCore.bulkUpdateGlobalUsers(toDelete) const dbResponse = await usersCore.bulkUpdateGlobalUsers(toDelete)
const creatorsToDelete = usersToDelete.filter(isCreator)
await UserDB.quotas.removeUsers(toDelete.length)
for (let user of usersToDelete) { for (let user of usersToDelete) {
await bulkDeleteProcessing(user) await bulkDeleteProcessing(user)
} }
await UserDB.quotas.removeUsers(toDelete.length, creatorsToDelete.length)
// Build Response // Build Response
// index users by id // index users by id
@ -473,7 +487,8 @@ export class UserDB {
await db.remove(userId, dbUser._rev) await db.remove(userId, dbUser._rev)
await UserDB.quotas.removeUsers(1) const creatorsToDelete = isCreator(dbUser) ? 1 : 0
await UserDB.quotas.removeUsers(1, creatorsToDelete)
await eventHelpers.handleDeleteEvents(dbUser) await eventHelpers.handleDeleteEvents(dbUser)
await cache.user.invalidateUser(userId) await cache.user.invalidateUser(userId)
await sessions.invalidateSessions(userId, { reason: "deletion" }) await sessions.invalidateSessions(userId, { reason: "deletion" })

View File

@ -14,14 +14,15 @@ import {
} from "../db" } from "../db"
import { import {
BulkDocsResponse, BulkDocsResponse,
ContextUser,
SearchQuery, SearchQuery,
SearchQueryOperators, SearchQueryOperators,
SearchUsersRequest, SearchUsersRequest,
User, User,
ContextUser,
} from "@budibase/types" } from "@budibase/types"
import * as context from "../context"
import { getGlobalDB } from "../context" import { getGlobalDB } from "../context"
import * as context from "../context"
import { isCreator } from "./utils"
type GetOpts = { cleanup?: boolean } type GetOpts = { cleanup?: boolean }
@ -283,6 +284,19 @@ export async function getUserCount() {
return response.total_rows return response.total_rows
} }
export async function getCreatorCount() {
let creators = 0
async function iterate(startPage?: string) {
const page = await paginatedUsers({ bookmark: startPage })
creators += page.data.filter(isCreator).length
if (page.hasNextPage) {
await iterate(page.nextPage)
}
}
await iterate()
return creators
}
// used to remove the builder/admin permissions, for processing the // used to remove the builder/admin permissions, for processing the
// user as an app user (they may have some specific role/group // user as an app user (they may have some specific role/group
export function removePortalUserPermissions(user: User | ContextUser) { export function removePortalUserPermissions(user: User | ContextUser) {

View File

@ -10,6 +10,7 @@ import { getAccountByTenantId } from "../accounts"
// extract from shared-core to make easily accessible from backend-core // extract from shared-core to make easily accessible from backend-core
export const isBuilder = sdk.users.isBuilder export const isBuilder = sdk.users.isBuilder
export const isAdmin = sdk.users.isAdmin export const isAdmin = sdk.users.isAdmin
export const isCreator = sdk.users.isCreator
export const isGlobalBuilder = sdk.users.isGlobalBuilder export const isGlobalBuilder = sdk.users.isGlobalBuilder
export const isAdminOrBuilder = sdk.users.isAdminOrBuilder export const isAdminOrBuilder = sdk.users.isAdminOrBuilder
export const hasAdminPermissions = sdk.users.hasAdminPermissions export const hasAdminPermissions = sdk.users.hasAdminPermissions

View File

@ -0,0 +1,54 @@
const _ = require('lodash/fp')
const {structures} = require("../../../tests")
jest.mock("../../../src/context")
jest.mock("../../../src/db")
const context = require("../../../src/context")
const db = require("../../../src/db")
const {getCreatorCount} = require('../../../src/users/users')
describe("Users", () => {
let getGlobalDBMock
let getGlobalUserParamsMock
let paginationMock
beforeEach(() => {
jest.resetAllMocks()
getGlobalDBMock = jest.spyOn(context, "getGlobalDB")
getGlobalUserParamsMock = jest.spyOn(db, "getGlobalUserParams")
paginationMock = jest.spyOn(db, "pagination")
})
it("Retrieves the number of creators", async () => {
const getUsers = (offset, limit, creators = false) => {
const range = _.range(offset, limit)
const opts = creators ? {builder: {global: true}} : undefined
return range.map(() => structures.users.user(opts))
}
const page1Data = getUsers(0, 8)
const page2Data = getUsers(8, 12, true)
getGlobalDBMock.mockImplementation(() => ({
name : "fake-db",
allDocs: () => ({
rows: [...page1Data, ...page2Data]
})
}))
paginationMock.mockImplementationOnce(() => ({
data: page1Data,
hasNextPage: true,
nextPage: "1"
}))
paginationMock.mockImplementation(() => ({
data: page2Data,
hasNextPage: false,
nextPage: undefined
}))
const creatorsCount = await getCreatorCount()
expect(creatorsCount).toBe(4)
expect(paginationMock).toHaveBeenCalledTimes(2)
})
})

View File

@ -72,6 +72,11 @@ export function quotas(): Quotas {
value: 1, value: 1,
triggers: [], triggers: [],
}, },
creators: {
name: "Creators",
value: 1,
triggers: [],
},
userGroups: { userGroups: {
name: "User Groups", name: "User Groups",
value: 1, value: 1,
@ -118,6 +123,10 @@ export function customer(): Customer {
export function subscription(): Subscription { export function subscription(): Subscription {
return { return {
amount: 10000, amount: 10000,
amounts: {
user: 10000,
creator: 0,
},
cancelAt: undefined, cancelAt: undefined,
currency: "usd", currency: "usd",
currentPeriodEnd: 0, currentPeriodEnd: 0,
@ -126,6 +135,10 @@ export function subscription(): Subscription {
duration: PriceDuration.MONTHLY, duration: PriceDuration.MONTHLY,
pastDueAt: undefined, pastDueAt: undefined,
quantity: 0, quantity: 0,
quantities: {
user: 0,
creator: 0,
},
status: "active", status: "active",
} }
} }

View File

@ -1,6 +1,6 @@
import { MonthlyQuotaName, QuotaUsage } from "@budibase/types" import { MonthlyQuotaName, QuotaUsage } from "@budibase/types"
export const usage = (): QuotaUsage => { export const usage = (users: number = 0, creators: number = 0): QuotaUsage => {
return { return {
_id: "usage_quota", _id: "usage_quota",
quotaReset: new Date().toISOString(), quotaReset: new Date().toISOString(),
@ -58,7 +58,8 @@ export const usage = (): QuotaUsage => {
usageQuota: { usageQuota: {
apps: 0, apps: 0,
plugins: 0, plugins: 0,
users: 0, users,
creators,
userGroups: 0, userGroups: 0,
rows: 0, rows: 0,
triggers: {}, triggers: {},

@ -1 +1 @@
Subproject commit 044bec6447066b215932d6726c437e7ec5a9e42e Subproject commit 570d14aa44aa88f4d053856322210f0008ba5c76

View File

@ -3,6 +3,7 @@ import * as syncApps from "./usageQuotas/syncApps"
import * as syncRows from "./usageQuotas/syncRows" import * as syncRows from "./usageQuotas/syncRows"
import * as syncPlugins from "./usageQuotas/syncPlugins" import * as syncPlugins from "./usageQuotas/syncPlugins"
import * as syncUsers from "./usageQuotas/syncUsers" import * as syncUsers from "./usageQuotas/syncUsers"
import * as syncCreators from "./usageQuotas/syncCreators"
/** /**
* Synchronise quotas to the state of the db. * Synchronise quotas to the state of the db.
@ -13,5 +14,6 @@ export const run = async () => {
await syncRows.run() await syncRows.run()
await syncPlugins.run() await syncPlugins.run()
await syncUsers.run() await syncUsers.run()
await syncCreators.run()
}) })
} }

View File

@ -0,0 +1,13 @@
import { users } from "@budibase/backend-core"
import { quotas } from "@budibase/pro"
import { QuotaUsageType, StaticQuotaName } from "@budibase/types"
export const run = async () => {
const creatorCount = await users.getCreatorCount()
console.log(`Syncing creator count: ${creatorCount}`)
await quotas.setUsage(
creatorCount,
StaticQuotaName.CREATORS,
QuotaUsageType.STATIC
)
}

View File

@ -0,0 +1,26 @@
import TestConfig from "../../../../tests/utilities/TestConfiguration"
import * as syncCreators from "../syncCreators"
import { quotas } from "@budibase/pro"
describe("syncCreators", () => {
let config = new TestConfig(false)
beforeEach(async () => {
await config.init()
})
afterAll(config.end)
it("syncs creators", async () => {
return config.doInContext(null, async () => {
await config.createUser({ admin: true })
await syncCreators.run()
const usageDoc = await quotas.getQuotaUsage()
// default + additional creator
const creatorsCount = 2
expect(usageDoc.usageQuota.creators).toBe(creatorsCount)
})
})
})

View File

@ -6,6 +6,7 @@ import {
InternalTable, InternalTable,
} from "@budibase/types" } from "@budibase/types"
import { getProdAppID } from "./applications" import { getProdAppID } from "./applications"
import * as _ from "lodash/fp"
// checks if a user is specifically a builder, given an app ID // checks if a user is specifically a builder, given an app ID
export function isBuilder(user: User | ContextUser, appId?: string): boolean { export function isBuilder(user: User | ContextUser, appId?: string): boolean {
@ -58,6 +59,18 @@ export function hasAppBuilderPermissions(user?: User | ContextUser): boolean {
return !isGlobalBuilder && appLength != null && appLength > 0 return !isGlobalBuilder && appLength != null && appLength > 0
} }
export function hasAppCreatorPermissions(user?: User | ContextUser): boolean {
if (!user) {
return false
}
return _.flow(
_.get("roles"),
_.values,
_.find(x => ["CREATOR", "ADMIN"].includes(x)),
x => !!x
)(user)
}
// checks if a user is capable of building any app // checks if a user is capable of building any app
export function hasBuilderPermissions(user?: User | ContextUser): boolean { export function hasBuilderPermissions(user?: User | ContextUser): boolean {
if (!user) { if (!user) {
@ -74,6 +87,18 @@ export function hasAdminPermissions(user?: User | ContextUser): boolean {
return !!user.admin?.global return !!user.admin?.global
} }
export function isCreator(user?: User | ContextUser): boolean {
if (!user) {
return false
}
return (
isGlobalBuilder(user) ||
hasAdminPermissions(user) ||
hasAppBuilderPermissions(user) ||
hasAppCreatorPermissions(user)
)
}
export function getGlobalUserID(userId?: string): string | undefined { export function getGlobalUserID(userId?: string): string | undefined {
if (typeof userId !== "string") { if (typeof userId !== "string") {
return userId return userId

View File

@ -32,6 +32,7 @@ export interface StaticUsage {
[StaticQuotaName.APPS]: number [StaticQuotaName.APPS]: number
[StaticQuotaName.PLUGINS]: number [StaticQuotaName.PLUGINS]: number
[StaticQuotaName.USERS]: number [StaticQuotaName.USERS]: number
[StaticQuotaName.CREATORS]: number
[StaticQuotaName.USER_GROUPS]: number [StaticQuotaName.USER_GROUPS]: number
[StaticQuotaName.ROWS]: number [StaticQuotaName.ROWS]: number
triggers: { triggers: {

View File

@ -1,5 +1,8 @@
export enum FeatureFlag { export enum FeatureFlag {
LICENSING = "LICENSING", LICENSING = "LICENSING",
// Feature IDs in Posthog
PER_CREATOR_PER_USER_PRICE = "18873",
PER_CREATOR_PER_USER_PRICE_ALERT = "18530",
} }
export interface TenantFeatureFlags { export interface TenantFeatureFlags {

View File

@ -5,10 +5,17 @@ export interface Customer {
currency: string | null | undefined currency: string | null | undefined
} }
export interface SubscriptionItems {
user: number | undefined
creator: number | undefined
}
export interface Subscription { export interface Subscription {
amount: number amount: number
amounts: SubscriptionItems | undefined
currency: string currency: string
quantity: number quantity: number
quantities: SubscriptionItems | undefined
duration: PriceDuration duration: PriceDuration
cancelAt: number | null | undefined cancelAt: number | null | undefined
currentPeriodStart: number currentPeriodStart: number

View File

@ -4,7 +4,9 @@ export enum PlanType {
PRO = "pro", PRO = "pro",
/** @deprecated */ /** @deprecated */
TEAM = "team", TEAM = "team",
/** @deprecated */
PREMIUM = "premium", PREMIUM = "premium",
PREMIUM_PLUS = "premium_plus",
BUSINESS = "business", BUSINESS = "business",
ENTERPRISE = "enterprise", ENTERPRISE = "enterprise",
} }
@ -26,10 +28,12 @@ export interface AvailablePrice {
currency: string currency: string
duration: PriceDuration duration: PriceDuration
priceId: string priceId: string
type?: string
} }
export enum PlanModel { export enum PlanModel {
PER_USER = "perUser", PER_USER = "perUser",
PER_CREATOR_PER_USER = "per_creator_per_user",
DAY_PASS = "dayPass", DAY_PASS = "dayPass",
} }

View File

@ -14,6 +14,7 @@ export enum StaticQuotaName {
ROWS = "rows", ROWS = "rows",
APPS = "apps", APPS = "apps",
USERS = "users", USERS = "users",
CREATORS = "creators",
USER_GROUPS = "userGroups", USER_GROUPS = "userGroups",
PLUGINS = "plugins", PLUGINS = "plugins",
} }
@ -67,6 +68,7 @@ export type StaticQuotas = {
[StaticQuotaName.ROWS]: Quota [StaticQuotaName.ROWS]: Quota
[StaticQuotaName.APPS]: Quota [StaticQuotaName.APPS]: Quota
[StaticQuotaName.USERS]: Quota [StaticQuotaName.USERS]: Quota
[StaticQuotaName.CREATORS]: Quota
[StaticQuotaName.USER_GROUPS]: Quota [StaticQuotaName.USER_GROUPS]: Quota
[StaticQuotaName.PLUGINS]: Quota [StaticQuotaName.PLUGINS]: Quota
} }