const db = require('../db/models'); const UsersDBApi = require('../db/api/users'); const { createEntityService } = require('../factories/service.factory'); const { assertCreateOptions, assertIdOptions, assertUpdateOptions, } = require('../contracts/entity-options'); const ValidationError = require('./notifications/errors/validation'); const config = require('../config'); const AuthService = require('./auth'); const { logger } = require('../utils/logger'); const AccessPolicy = require('./access-policy'); // Generate base service from factory const BaseUsersService = createEntityService(UsersDBApi, { entityName: 'Users', }); /** * Users service with email invitation functionality * Extends factory-generated service with custom user logic */ class UsersService extends BaseUsersService { static normalizeIdArray(value) { if (!Array.isArray(value)) return []; return value .map((item) => { if (typeof item === 'string') return item; if (item && typeof item === 'object') return item.id || item.value; return null; }) .filter(Boolean); } static normalizeRoleId(value) { if (!value) return null; if (typeof value === 'string') return value; if (typeof value === 'object') return value.id || value.value || null; return null; } static async createProductionPresentationAccessForPublicUser({ user, data, currentUser, transaction, }) { if (!user?.id) return; await db.production_presentation_access.destroy({ where: { userId: user.id }, transaction, }); const selectedProjectIds = this.normalizeIdArray( data.allowed_private_production_project_ids, ); const roleId = this.normalizeRoleId(data.app_role); if (!selectedProjectIds.length || !roleId) return; const role = await db.roles.findByPk(roleId, { transaction }); if (role?.name !== 'Public') return; const privateProjects = await db.projects.findAll({ where: { id: { [db.Sequelize.Op.in]: selectedProjectIds, }, production_presentation_visibility: 'private', }, attributes: ['id'], transaction, }); if (!privateProjects.length) return; const now = new Date(); await db.production_presentation_access.bulkCreate( privateProjects.map((project) => ({ projectId: project.id, userId: user.id, createdById: currentUser?.id || null, updatedById: currentUser?.id || null, createdAt: now, updatedAt: now, })), { ignoreDuplicates: true, transaction }, ); } static async assertPublicUserHasNoAdminPermissions(data, transaction) { const roleId = this.normalizeRoleId(data.app_role); if (!roleId) return false; const role = await db.roles.findByPk(roleId, { transaction }); if (role?.name !== 'Public') return false; const customPermissions = this.normalizeIdArray(data.custom_permissions); if (customPermissions.length > 0) { throw new ValidationError( 'Public users cannot receive custom permissions', ); } return true; } static async updateProductionPresentationAccessForPublicUser({ user, data, currentUser, transaction, }) { if (!user?.id) return; await db.production_presentation_access.destroy({ where: { userId: user.id }, transaction, }); await this.createProductionPresentationAccessForPublicUser({ user, data, currentUser, transaction, }); } /** * Create user with email validation and optional invitation */ static async create(options) { assertCreateOptions(options, 'Service'); const { data, currentUser, sendInvitationEmails = true, host, transaction: externalTransaction, runtimeContext, } = options; const transaction = externalTransaction || (await db.sequelize.transaction()); const ownsTransaction = !externalTransaction; const email = data.email; try { if (!email) { throw new ValidationError('iam.errors.emailRequired'); } const existingUser = await db.users.findOne({ where: { email }, paranoid: false, transaction, }); if (existingUser && !existingUser.deletedAt) { throw new ValidationError('iam.errors.userAlreadyExists'); } const isPublicUserData = await this.assertPublicUserHasNoAdminPermissions( data, transaction, ); const sanitizedData = isPublicUserData ? { ...data, custom_permissions: [] } : data; let user; if (existingUser?.deletedAt) { await existingUser.restore({ transaction }); user = await UsersDBApi.update({ id: existingUser.id, data: sanitizedData, currentUser, transaction, runtimeContext, }); } else { user = await UsersDBApi.create({ data: sanitizedData, currentUser, transaction, runtimeContext, }); } await this.createProductionPresentationAccessForPublicUser({ user, data: sanitizedData, currentUser, transaction, }); if (ownsTransaction) await transaction.commit(); // Send invitation email after successful commit if (sendInvitationEmails) { AuthService.sendPasswordResetEmail(email, 'invitation', host).catch( (error) => { logger.error( { err: error, email }, 'Failed to send user invitation email', ); }, ); } } catch (error) { if (ownsTransaction) await transaction.rollback(); throw error; } } static async update(options) { assertUpdateOptions(options, 'Service'); const { id, data, currentUser, transaction: externalTransaction, runtimeContext, } = options; const transaction = externalTransaction || (await db.sequelize.transaction()); const ownsTransaction = !externalTransaction; try { const existingUser = await UsersDBApi.findBy( { id }, { transaction, runtimeContext }, ); if (!existingUser) { throw new ValidationError('UsersNotFound'); } const roleId = this.normalizeRoleId(data.app_role); const role = roleId ? await db.roles.findByPk(roleId, { transaction }) : null; const nextRoleName = role?.name || existingUser.app_role?.name; const nextUser = { ...existingUser, app_role: { name: nextRoleName }, custom_permissions: data.custom_permissions || existingUser.custom_permissions, }; if (AccessPolicy.isPublicUser(nextUser)) { await this.assertPublicUserHasNoAdminPermissions( { ...data, app_role: roleId || existingUser.app_role?.id, }, transaction, ); } const sanitizedData = AccessPolicy.isPublicUser(nextUser) ? { ...data, custom_permissions: [] } : data; const user = await UsersDBApi.update({ id, data: sanitizedData, currentUser, transaction, runtimeContext, }); if ( Object.prototype.hasOwnProperty.call( data, 'allowed_private_production_project_ids', ) ) { await this.updateProductionPresentationAccessForPublicUser({ user, data, currentUser, transaction, }); } if (ownsTransaction) await transaction.commit(); return user; } catch (error) { if (ownsTransaction) await transaction.rollback(); throw error; } } /** * Remove user with self-deletion and permission checks */ static async remove(options) { assertIdOptions(options, 'Service', 'remove'); const { id, currentUser, transaction, runtimeContext } = options; if (currentUser.id === id) { throw new ValidationError('iam.errors.deletingHimself'); } if (currentUser.app_role?.name !== config.roles.admin) { throw new ValidationError('errors.forbidden.message'); } // Delegate to parent (factory) implementation return super.remove({ id, currentUser, transaction, runtimeContext }); } } module.exports = UsersService;