2026-06-29 09:38:55 +02:00

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;