diff --git a/packages/server/modules/workspaces/domain/constants.ts b/packages/server/modules/workspaces/domain/constants.ts index 79f477798..c86914ea8 100644 --- a/packages/server/modules/workspaces/domain/constants.ts +++ b/packages/server/modules/workspaces/domain/constants.ts @@ -1,10 +1,5 @@ export const WorkspaceInviteResourceType = 'workspace' -export const WORKSPACE_COST_ADMIN = 70 -export const WORKSPACE_COST_MEMBER = 50 -export const WORKSPACE_COST_GUEST = 10 -export const WORKSPACE_COST_VIEWER = 0 - export const WorkspaceEarlyAdopterDiscount = { name: '50% Early Adopter Discount', amount: 0.5 diff --git a/packages/server/modules/workspaces/domain/operations.ts b/packages/server/modules/workspaces/domain/operations.ts index fc55492eb..03168be29 100644 --- a/packages/server/modules/workspaces/domain/operations.ts +++ b/packages/server/modules/workspaces/domain/operations.ts @@ -169,5 +169,14 @@ export type CountProjectsVersionsByWorkspaceId = (args: { export type CountWorkspaceRoleWithOptionalProjectRole = (args: { workspaceId: string workspaceRole: WorkspaceRoles - projectRole?: StreamRoles | undefined + projectRole?: StreamRoles + skipUserIds?: string[] }) => Promise + +export type GetUserIdsWithRoleInWorkspace = ( + args: { + workspaceId: string + workspaceRole: WorkspaceRoles + }, + options?: { limit?: number } +) => Promise diff --git a/packages/server/modules/workspaces/graph/resolvers/workspaces.ts b/packages/server/modules/workspaces/graph/resolvers/workspaces.ts index 3cffa37e9..749b46bdc 100644 --- a/packages/server/modules/workspaces/graph/resolvers/workspaces.ts +++ b/packages/server/modules/workspaces/graph/resolvers/workspaces.ts @@ -74,7 +74,8 @@ import { getUserDiscoverableWorkspacesFactory, getWorkspaceWithDomainsFactory, countProjectsVersionsByWorkspaceIdFactory, - countWorkspaceRoleWithOptionalProjectRoleFactory + countWorkspaceRoleWithOptionalProjectRoleFactory, + getUserIdsWithRoleInWorkspaceFactory } from '@/modules/workspaces/repositories/workspaces' import { buildWorkspaceInviteEmailContentsFactory, @@ -682,7 +683,10 @@ export = FF_WORKSPACES_MODULE_ENABLED const workspaceId = (parent as unknown as { parent: Workspace }).parent.id return getWorkspaceCostFactory({ getWorkspaceCostItems: getWorkspaceCostItemsFactory({ - countRole: countWorkspaceRoleWithOptionalProjectRoleFactory({ db }) + countRole: countWorkspaceRoleWithOptionalProjectRoleFactory({ db }), + getUserIdsWithRoleInWorkspace: getUserIdsWithRoleInWorkspaceFactory({ + db + }) }), discount: WorkspaceEarlyAdopterDiscount })({ workspaceId }) diff --git a/packages/server/modules/workspaces/repositories/workspaces.ts b/packages/server/modules/workspaces/repositories/workspaces.ts index 68ce6078c..ea999884a 100644 --- a/packages/server/modules/workspaces/repositories/workspaces.ts +++ b/packages/server/modules/workspaces/repositories/workspaces.ts @@ -11,6 +11,7 @@ import { DeleteWorkspaceDomain, DeleteWorkspaceRole, GetUserDiscoverableWorkspaces, + GetUserIdsWithRoleInWorkspace, GetWorkspace, GetWorkspaceCollaborators, GetWorkspaceCollaboratorsTotalCount, @@ -346,11 +347,33 @@ export const countProjectsVersionsByWorkspaceIdFactory = return parseInt(res.count.toString()) } +export const getUserIdsWithRoleInWorkspaceFactory = + ({ db }: { db: Knex }): GetUserIdsWithRoleInWorkspace => + async ({ workspaceId, workspaceRole }, options) => { + const query = tables + .workspacesAcl(db) + .where(DbWorkspaceAcl.col.workspaceId, workspaceId) + .where(DbWorkspaceAcl.col.role, workspaceRole) + .orderBy(DbWorkspaceAcl.col.createdAt) + + if (options?.limit) { + query.limit(options.limit) + } + + return ( + (await query.select([DbWorkspaceAcl.col.userId])) as Pick< + WorkspaceAcl, + 'userId' + >[] + ).map((wsAcl) => wsAcl.userId) + } + export const countWorkspaceRoleWithOptionalProjectRoleFactory = ({ db }: { db: Knex }): CountWorkspaceRoleWithOptionalProjectRole => - async ({ workspaceId, workspaceRole, projectRole }) => { + async ({ workspaceId, workspaceRole, projectRole, skipUserIds }) => { + let query if (projectRole) { - const query = tables + query = tables .streams(db) .join(StreamAcl.name, StreamAcl.col.resourceId, Streams.col.id) .join(DbWorkspaceAcl.name, DbWorkspaceAcl.col.userId, StreamAcl.col.userId) @@ -360,17 +383,18 @@ export const countWorkspaceRoleWithOptionalProjectRoleFactory = .andWhere(DbWorkspaceAcl.col.workspaceId, workspaceId) .andWhere(StreamAcl.col.role, projectRole) .countDistinct(DbWorkspaceAcl.col.userId) - - const [res] = await query - return parseInt(res.count.toString()) } else { - const query = tables + query = tables .workspacesAcl(db) .where(DbWorkspaceAcl.col.workspaceId, workspaceId) .where(DbWorkspaceAcl.col.role, workspaceRole) .count() - - const [res] = await query - return parseInt(res.count.toString()) } + + if (skipUserIds) { + query.whereNotIn(DbWorkspaceAcl.col.userId, skipUserIds) + } + + const [res] = await query + return parseInt(res.count.toString()) } diff --git a/packages/server/modules/workspaces/services/cost.ts b/packages/server/modules/workspaces/services/cost.ts index 901996263..627bd949c 100644 --- a/packages/server/modules/workspaces/services/cost.ts +++ b/packages/server/modules/workspaces/services/cost.ts @@ -1,11 +1,14 @@ -import { CountWorkspaceRoleWithOptionalProjectRole } from '@/modules/workspaces/domain/operations' +import { + CountWorkspaceRoleWithOptionalProjectRole, + GetUserIdsWithRoleInWorkspace +} from '@/modules/workspaces/domain/operations' import { Roles, throwUncoveredError } from '@speckle/shared' type KnownWorkspaceCostItemNames = - | 'workspace admin' - | 'workspace member' - | 'read/write guest' - | 'read only guest' + | 'workspace members' + | 'free guests' + | 'read/write guests' + | 'read only guests' type KnownCurrencies = 'GBP' @@ -23,14 +26,14 @@ const getWorkspaceCostItemCost = ({ currency?: KnownCurrencies }): number => { switch (name) { - case 'workspace admin': - return 70 - case 'workspace member': - return 50 - case 'read/write guest': - return 10 - case 'read only guest': + case 'workspace members': + return 49 + case 'free guests': return 0 + case 'read/write guests': + return 15 + case 'read only guests': + return 5 default: throwUncoveredError(name) } @@ -42,11 +45,20 @@ type GetWorkspaceCostItems = (args: { export const getWorkspaceCostItemsFactory = ({ - countRole + countRole, + getUserIdsWithRoleInWorkspace }: { countRole: CountWorkspaceRoleWithOptionalProjectRole + getUserIdsWithRoleInWorkspace: GetUserIdsWithRoleInWorkspace }): GetWorkspaceCostItems => async ({ workspaceId }) => { + const freeGuestsIds = await getUserIdsWithRoleInWorkspace( + { + workspaceId, + workspaceRole: Roles.Workspace.Guest + }, + { limit: 10 } + ) const [adminCount, memberCount, writeGuestCount, readGuestCount] = await Promise.all([ countRole({ workspaceId, workspaceRole: Roles.Workspace.Admin }), @@ -54,45 +66,43 @@ export const getWorkspaceCostItemsFactory = countRole({ workspaceId, workspaceRole: Roles.Workspace.Guest, - projectRole: Roles.Stream.Contributor + projectRole: Roles.Stream.Contributor, + skipUserIds: freeGuestsIds }), countRole({ workspaceId, workspaceRole: Roles.Workspace.Guest, - projectRole: Roles.Stream.Reviewer + projectRole: Roles.Stream.Reviewer, + skipUserIds: freeGuestsIds }) ]) const workspaceCostItems: WorkspaceCostItem[] = [] - if (adminCount) - workspaceCostItems.push({ - name: 'workspace admin', - description: 'Workspace administrator with all the powers', - count: adminCount, - cost: getWorkspaceCostItemCost({ name: 'workspace admin' }) - }) - if (memberCount) - workspaceCostItems.push({ - name: 'workspace member', - description: 'General workspace member', - count: memberCount, - cost: getWorkspaceCostItemCost({ name: 'workspace member' }) - }) - if (writeGuestCount) - workspaceCostItems.push({ - name: 'read/write guest', - description: 'Workspace guest with write access to minimum 1 workspace project', - count: writeGuestCount, - cost: getWorkspaceCostItemCost({ name: 'read/write guest' }) - }) - if (readGuestCount) - workspaceCostItems.push({ - name: 'read only guest', - description: 'Workspace guest with only read access to some workspace projects', - count: readGuestCount, - cost: getWorkspaceCostItemCost({ name: 'read only guest' }) - }) + workspaceCostItems.push({ + name: 'workspace members', + description: 'General workspace member', + count: adminCount + memberCount, + cost: getWorkspaceCostItemCost({ name: 'workspace members' }) + }) + workspaceCostItems.push({ + name: 'free guests', + description: 'The first 10 workspace guests are free', + count: freeGuestsIds.length, + cost: getWorkspaceCostItemCost({ name: 'free guests' }) + }) + workspaceCostItems.push({ + name: 'read/write guests', + description: 'Workspace guests with write access to minimum 1 workspace project', + count: writeGuestCount, + cost: getWorkspaceCostItemCost({ name: 'read/write guests' }) + }) + workspaceCostItems.push({ + name: 'read only guests', + description: 'Workspace guests with only read access to some workspace projects', + count: readGuestCount, + cost: getWorkspaceCostItemCost({ name: 'read only guests' }) + }) return workspaceCostItems } diff --git a/packages/server/modules/workspaces/tests/integration/workspaces.graph.spec.ts b/packages/server/modules/workspaces/tests/integration/workspaces.graph.spec.ts index fcca19097..68083d227 100644 --- a/packages/server/modules/workspaces/tests/integration/workspaces.graph.spec.ts +++ b/packages/server/modules/workspaces/tests/integration/workspaces.graph.spec.ts @@ -391,6 +391,7 @@ describe('Workspaces GQL CRUD', () => { max: 500 }) }) + it('should return workspace cost', async () => { const createRes = await apollo.execute(CreateWorkspaceDocument, { input: { name: createRandomString() } @@ -406,6 +407,11 @@ describe('Workspaces GQL CRUD', () => { name: createRandomPassword(), email: createRandomEmail() } + const freeGuests = new Array(10).fill(0).map(() => ({ + id: createRandomString(), + name: createRandomPassword(), + email: createRandomEmail() + })) const guestWithWritePermission = { id: createRandomString(), name: createRandomPassword(), @@ -422,6 +428,14 @@ describe('Workspaces GQL CRUD', () => { email: createRandomEmail() } + // first 10 users + await createTestUsers(freeGuests) + await Promise.all( + freeGuests.map((guest) => + assignToWorkspace(workspace, guest, Roles.Workspace.Guest) + ) + ) + await Promise.all([ createTestUser(member), createTestUser(guestWithWritePermission), @@ -470,32 +484,32 @@ describe('Workspaces GQL CRUD', () => { expect(res).to.not.haveGraphQLErrors() const { subTotal, currency, items, total, discount } = res.data!.workspace.billing.cost - expect(subTotal).to.equal(70 + 50 + 10) + expect(subTotal).to.equal(49 + 49 + 15 + 2 * 5) expect(currency).to.equal('GBP') expect(items).to.deep.equal([ { - name: 'workspace admin', - count: 1, - cost: 70 - }, - { - name: 'workspace member', - count: 1, - cost: 50 - }, - { - name: 'read/write guest', - count: 1, - cost: 10 - }, - { - name: 'read only guest', + name: 'workspace members', count: 2, + cost: 49 + }, + { + name: 'free guests', + count: 10, cost: 0 + }, + { + name: 'read/write guests', + count: 1, + cost: 15 + }, + { + name: 'read only guests', + count: 2, + cost: 5 } ]) expect(discount).to.deep.equal(WorkspaceEarlyAdopterDiscount) - expect(total).to.equal(65) + expect(total).to.equal(61.5) }) })