317 lines
8.3 KiB
JavaScript
317 lines
8.3 KiB
JavaScript
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;
|