UNPKG

@flowfuse/flowfuse

Version:

An open source low-code development platform

688 lines (671 loc) • 32.3 kB
/** * A Team * @namespace forge.db.models.Team */ const { DataTypes, literal, Op } = require('sequelize') const { Roles } = require('../../lib/roles') const { slugify, generateTeamAvatar, buildPaginationSearchClause } = require('../utils') module.exports = { name: 'Team', schema: { name: { type: DataTypes.STRING, allowNull: false, validate: { not: /:\/\// } }, slug: { type: DataTypes.STRING, unique: true, validate: { is: /^[a-z0-9-_]+$/i } }, suspended: { type: DataTypes.BOOLEAN, defaultValue: false }, avatar: { type: DataTypes.STRING } }, hooks: function (M, app) { return { beforeCreate: async (team, options) => { if (!team.TeamTypeId) { throw new Error('Cannot create team without TeamTypeId') } // if the product is licensed, we permit overage const isLicensed = app.license.active() if (isLicensed !== true) { const { teams } = await app.license.usage('teams') if (teams.count >= teams.limit) { throw new Error('license limit reached') } } }, afterCreate: async (team, options) => { const { teams } = await app.license.usage('teams') if (teams.count > teams.limit) { await app.auditLog.Platform.platform.license.overage('system', null, teams) } }, beforeSave: (team, options) => { if (!team.avatar) { team.avatar = generateTeamAvatar(team.name) } if (!team.slug) { team.slug = slugify(team.name) } team.slug = team.slug.toLowerCase() }, beforeDestroy: async (team, opts) => { const instanceCount = await team.instanceCount() if (instanceCount > 0) { throw new Error('Cannot delete team that owns instances') } }, afterDestroy: async (team, opts) => { // TODO: what needs tidying up after a team is deleted? // Doing this here also clears historical invites. // TeamId is null because there is a cascade rule await M.Invitation.destroy({ where: { teamId: null } }) // This should only be empty Applications as the // beforeDestroy hook will block deletion of the // Team if any Applications have Instances await M.Application.destroy({ where: { TeamId: team.id } }) // Remove Team's device provisioning tokens await M.AccessToken.destroy({ where: { ownerType: 'team', ownerId: team.id.toString() } }) // Remove all Team's npm tokens await M.AccessToken.destroy({ where: { ownerType: 'npm', ownerId: { [Op.like]: `%@${team.hashid}` } } }) } } }, associations: function (M) { this.belongsToMany(M.User, { through: M.TeamMember }) this.belongsTo(M.TeamType) this.hasMany(M.TeamMember) this.hasMany(M.Device) this.hasMany(M.Project) this.hasMany(M.Invitation, { foreignKey: 'teamId' }) this.hasMany(M.Application) }, finders: function (M, app) { const self = this return { static: { byId: async function (id) { if (typeof id === 'string') { id = M.Team.decodeHashid(id) } return self.findOne({ where: { id }, include: [{ model: M.TeamType }], attributes: { include: [ [ literal(`( SELECT COUNT(*) FROM "Projects" AS "project" WHERE "project"."TeamId" = "Team"."id" )`), 'projectCount' ], [ literal(`( SELECT COUNT(*) FROM "TeamMembers" AS "members" WHERE "members"."TeamId" = "Team"."id" )`), 'memberCount' ], [ literal(`( SELECT COUNT(*) FROM "Devices" AS "devices" WHERE "devices"."TeamId" = "Team"."id" )`), 'deviceCount' ] ] } }) }, bySlug: async function (slug) { return self.findOne({ where: { slug }, include: [{ model: M.TeamType }], attributes: { include: [ [ literal(`( SELECT COUNT(*) FROM "Projects" AS "project" WHERE "project"."TeamId" = "Team"."id" )`), 'projectCount' ], [ literal(`( SELECT COUNT(*) FROM "TeamMembers" AS "members" WHERE "members"."TeamId" = "Team"."id" )`), 'memberCount' ], [ literal(`( SELECT COUNT(*) FROM "Devices" AS "devices" WHERE "devices"."TeamId" = "Team"."id" )`), 'deviceCount' ] ] } }) }, byName: async function (name) { // This is primarily used by the unit tests. return self.findOne({ where: { name }, include: [{ model: M.TeamType }] }) }, countForUser: async function (User) { return M.TeamMember.count({ where: { UserId: User.id } }) }, forUser: async function (User) { return M.TeamMember.findAll({ where: { UserId: User.id }, include: { model: M.Team, attributes: ['hashid', 'links', 'id', 'name', 'avatar', 'slug', 'suspended'], include: { model: M.TeamType, attributes: ['hashid', 'id', 'name'] } }, attributes: { include: [ [ literal(`( SELECT COUNT(*) FROM "Projects" AS "project" WHERE "project"."TeamId" = "TeamMember"."TeamId" )`), 'projectCount' ], [ literal(`( SELECT COUNT(*) FROM "TeamMembers" AS "members" WHERE "members"."TeamId" = "Team"."id" )`), 'memberCount' ], [ literal(`( SELECT COUNT(*) FROM "Devices" AS "devices" WHERE "devices"."TeamId" = "Team"."id" )`), 'deviceCount' ] ] } }) }, getAll: async (pagination = {}, where = {}) => { const limit = parseInt(pagination.limit) || 1000 if (pagination.cursor) { pagination.cursor = M.Team.decodeHashid(pagination.cursor) } where = buildPaginationSearchClause(pagination, where, ['Team.name']) if (pagination.query) { const queryId = M.Team.decodeHashid(pagination.query) if (queryId && queryId.length === 1) { // The query term is a valid hashid - go look it up where = { id: queryId } } } const order = [['id', 'ASC']] if (pagination.sort === 'createdAt-desc') { order[0][1] = 'DESC' // Also need to 'fix' the cursor pagination if (pagination.cursor) { where[Op.and].forEach(rule => { if (rule.id) { rule.id = { [Op.lt]: pagination.cursor } } }) } } const include = [{ model: M.TeamType, attributes: ['hashid', 'id', 'name'] }] if (app.billing) { // Include subscription info include.push({ model: app.db.models.Subscription }) } const [rows, count] = await Promise.all([ this.findAll({ where, order, limit, include, attributes: { include: [ [ literal(`( SELECT COUNT(*) FROM "Projects" AS "project" WHERE "project"."TeamId" = "Team"."id" )`), 'projectCount' ], [ literal(`( SELECT COUNT(*) FROM "TeamMembers" AS "members" WHERE "members"."TeamId" = "Team"."id" )`), 'memberCount' ], [ literal(`( SELECT COUNT(*) FROM "Devices" AS "devices" WHERE "devices"."TeamId" = "Team"."id" )`), 'deviceCount' ] ] } }), this.count({ where, include }) ]) return { meta: { next_cursor: rows.length === limit ? rows[rows.length - 1].hashid : undefined }, count, teams: rows } } }, instance: { getOwners: async function () { const where = { TeamId: this.id, role: Roles.Owner } const owners = (await M.TeamMember.findAll({ where, include: M.User })).map(tm => tm.User) // There is a race condition (though it shouldn't happen) where a user, but not their membership, has been deleted // In this case the findAll above will return an array that includes null, this needs to be guarded against return owners.filter((owner) => owner !== null) }, /** * Get all members of the team optionally filtered by `role` array * @param {Array<Number> | null} roleFilter - Array of roles to filter by * @example * // Get all members of the team * const members = await team.getTeamMembers() * @example * // Get viewers only * const viewers = await team.getTeamMembers([Roles.Viewer]) */ getTeamMembers: async function (roleFilter = null) { const where = { TeamId: this.id } if (roleFilter && Array.isArray(roleFilter)) { where.role = roleFilter } return (await M.TeamMember.findAll({ where, include: M.User })).filter(tm => tm && tm.User).map(tm => tm.User) }, memberCount: async function (role) { const where = { TeamId: this.id } if (role !== undefined) { where.role = role } return await M.TeamMember.count({ where }) }, ownerCount: async function () { // All Team owners return this.memberCount(Roles.Owner) }, instanceCount: async function (projectTypeId) { const where = { TeamId: this.id } if (projectTypeId) { if (typeof projectTypeId === 'string') { projectTypeId = M.ProjectType.decodeHashid(projectTypeId) } else if (projectTypeId.id) { projectTypeId = projectTypeId.id } where.ProjectTypeId = projectTypeId } return await M.Project.count({ where }) }, instanceCountByType: async function (where = {}) { where = { ...where, TeamId: this.id } const counts = await M.Project.count({ where, attributes: ['ProjectTypeId'], group: 'ProjectTypeId' }) const result = {} for (const instanceType of Object.values(counts)) { result[M.ProjectType.encodeHashid(instanceType.ProjectTypeId)] = instanceType.count } return result }, pendingInviteCount: async function () { return await M.Invitation.count({ where: { teamId: this.id } }) }, deviceCount: async function () { return await M.Device.count({ where: { TeamId: this.id } }) }, /** * Many functions require this.TeamType to exist and be fully populated. * Depending on the route taken, it is possible this property has not * been fully loaded. This does the work to ensure it is there if needed. */ ensureTeamTypeExists: async function () { if (!this.TeamTypeId) { await this.reload({ include: [{ model: M.TeamType }] }) } else if (!this.TeamType) { // TeamTypeId is present, but no TeamType this.TeamType = await this.getTeamType() } }, getUserLimit: async function () { await this.ensureTeamTypeExists() return this.TeamType.getProperty('users.limit', -1) }, getRuntimeLimit: async function () { await this.ensureTeamTypeExists() return this.TeamType.getProperty('runtimes.limit', -1) }, getDeviceLimit: async function () { await this.ensureTeamTypeExists() return this.TeamType.getProperty('devices.limit', -1) }, checkDeviceCreateAllowed: async function () { if (this.suspended) { const err = new Error() err.code = 'team_suspended' err.error = 'Team suspended' throw err } // Check for a specific device limit const deviceLimit = await this.getDeviceLimit() let currentDeviceCount = null if (deviceLimit > -1) { currentDeviceCount = await this.deviceCount() if (currentDeviceCount >= deviceLimit) { const err = new Error() err.code = 'device_limit_reached' err.error = 'Team device limit reached' throw err } } // Check for a combined instance+device limit const runtimeLimit = await this.getRuntimeLimit() if (runtimeLimit > -1) { if (currentDeviceCount === null) { currentDeviceCount = await this.deviceCount() } const currentInstanceCount = await this.instanceCount() const currentRuntimeCount = currentDeviceCount + currentInstanceCount if (currentRuntimeCount >= runtimeLimit) { const err = new Error() err.code = 'device_limit_reached' err.error = 'Team device limit reached' throw err } } }, isInstanceTypeAvailable: async function (instanceType) { await this.ensureTeamTypeExists() return this.TeamType.getInstanceTypeProperty(instanceType, 'active', false) }, isInstanceTypeCreatable: async function (instanceType) { await this.ensureTeamTypeExists() // Defaults to true unless explicit disabled return this.TeamType.getInstanceTypeProperty(instanceType, 'creatable', true) }, getInstanceTypeLimit: async function (instanceType) { await this.ensureTeamTypeExists() if (!await this.isInstanceTypeAvailable(instanceType)) { return 0 } return this.TeamType.getInstanceTypeProperty(instanceType, 'limit', -1) }, /** * Checks if this team is allowed to create a new instance of the * given type. * At this level, the check looks at any restrictions applied * by the TeamType object. * When running with EE, this function is overloaded via * ee/lib/billing/Team.js to add EE-specific checks as well * (such as billing and trials) * * If the create is not allowed, an error is thrown with code/error * properties set * @param {object} instanceType - a fully populated ProjectType object */ checkInstanceTypeCreateAllowed: async function (instanceType) { if (this.suspended) { const err = new Error() err.code = 'team_suspended' err.error = 'Team suspended' throw err } await this.ensureTeamTypeExists() const typeAvailable = await this.isInstanceTypeAvailable(instanceType) const typeCreatable = await this.isInstanceTypeCreatable(instanceType) if (!typeAvailable || !typeCreatable) { const err = new Error() err.code = 'instance_not_available' err.error = `Instance type '${instanceType.name}' not available for this team` throw err } const instanceTypeLimit = await this.getInstanceTypeLimit(instanceType) // Note that if the instanceType is unavailable for this team type, // its limit is implicitly set to 0 if (instanceTypeLimit > -1) { // This team type has a limit on how many instances of this type // can be created. Ensure we're within that limit const currentInstanceCount = await this.instanceCount(instanceType.hashid) if (currentInstanceCount >= instanceTypeLimit) { const err = new Error() err.code = 'instance_limit_reached' err.error = `Team instance limit reached for type '${instanceType.name}'` throw err } } // Check for a combined instance+device limit const runtimeLimit = await this.getRuntimeLimit() if (runtimeLimit > -1) { const currentDeviceCount = await this.deviceCount() const currentInstanceCount = await this.instanceCount() const currentRuntimeCount = currentDeviceCount + currentInstanceCount if (currentRuntimeCount >= runtimeLimit) { const err = new Error() err.code = 'instance_limit_reached' err.error = 'Team instance limit reached' throw err } } }, /** * Checks whether an instance may be started in this team. For CE * platforms, there are no restrictions on unsuspending an instance. * * When running with EE, this function is replaced via ee/lib/billing/Team.js * to add additional checks * @param {*} instance The instance to start * Throws an error if it is not allowed */ checkInstanceStartAllowed: async function (instance) { if (this.suspended) { const err = new Error() err.code = 'team_suspended' err.error = 'Team suspended' throw err } return true }, /** * Checks whether the team type can be modified to the requested type. * The team must be within any limits of the target type. */ checkTeamTypeUpdateAllowed: async function (teamType) { await this.ensureTeamTypeExists() // Check the following limits: // - User count // - Device count // - Instance Type counts const errors = [] const currentMemberCount = await this.memberCount() const targetMemberLimit = teamType.getProperty('users.limit', -1) if (targetMemberLimit !== -1 && targetMemberLimit < currentMemberCount) { errors.push({ code: 'member_limit_reached', error: 'Member limit reached', limit: targetMemberLimit, count: currentMemberCount }) } const currentDeviceCount = await this.deviceCount() const targetDeviceLimit = teamType.getProperty('devices.limit', -1) if (targetDeviceLimit !== -1 && targetDeviceLimit < currentDeviceCount) { errors.push({ code: 'device_limit_reached', error: 'Device limit reached', limit: targetDeviceLimit, count: currentDeviceCount }) } const currentInstanceCountsByType = await this.instanceCountByType() const targetInstanceLimits = {} let totalInstanceCount = 0 for (const instanceType of Object.keys(currentInstanceCountsByType)) { if (!teamType.getInstanceTypeProperty(instanceType, 'active', false)) { targetInstanceLimits[instanceType] = 0 } else { targetInstanceLimits[instanceType] = teamType.getInstanceTypeProperty(instanceType, 'limit', -1) } totalInstanceCount += currentInstanceCountsByType[instanceType] if (targetInstanceLimits[instanceType] !== -1 && targetInstanceLimits[instanceType] < currentInstanceCountsByType[instanceType]) { errors.push({ code: 'instance_limit_reached', error: `Instance type ${instanceType} limit reached`, type: instanceType, limit: targetInstanceLimits[instanceType], count: currentInstanceCountsByType[instanceType] }) } } // Check for a combined instance+device limit const runtimeLimit = await this.getRuntimeLimit() if (runtimeLimit > -1) { const currentRuntimeCount = currentDeviceCount + totalInstanceCount if (currentRuntimeCount > runtimeLimit) { errors.push({ code: 'instance_limit_reached', error: 'Instance limit reached', limit: runtimeLimit, count: currentRuntimeCount }) } } if (errors.length > 0) { const message = errors.map(err => `${err.error} (current: ${err.count}, limit: ${err.limit})`).join(', ') const err = new Error(`Unable to change team type: ${message}`) err.code = 'invalid_request' err.errors = errors throw err } }, /** * Update the team type. * * When running with EE, this function is replaced via ee/lib/billing/Team.js * to add additional checks related to billing */ updateTeamType: async function (teamType) { await this.setTeamType(teamType) await this.save() await this.reload({ include: [{ model: M.TeamType }] }) }, /** * Suspend the team * - sets suspended=true * - suspends all team instances * * When running with EE, this function is replaced via ee/lib/billing/Team.js * to complete billing related operations first */ suspend: async function () { this.suspended = true await this.save() // get list of running Instances const instanceList = await app.db.models.Project.findAll({ attributes: [ 'id', 'state', 'ProjectStackId', 'TeamId' ], where: { TeamId: this.id, state: { [Op.eq]: 'running' } } }) // Shut down all running instances instanceList.forEach(async (instance) => { try { await app.containers.stop(instance) } catch (err) { // do we need to log a failure? } }) }, /** * Unsuspend a team */ unsuspend: async function () { this.suspended = false await this.save() } } } } }