diff --git a/backend/src/contracts/entity-options.js b/backend/src/contracts/entity-options.js new file mode 100644 index 0000000..2f11d26 --- /dev/null +++ b/backend/src/contracts/entity-options.js @@ -0,0 +1,95 @@ +/** + * @typedef {Object} ServiceCreateOptions + * @property {Object} data + * @property {Object} [currentUser] + * @property {Object} [transaction] + * @property {Object} [runtimeContext] + * @property {boolean} [sendInvitationEmails] + * @property {string} [host] + */ + +/** + * @typedef {Object} EntityIdOptions + * @property {string} id + * @property {Object} [currentUser] + * @property {Object} [transaction] + * @property {Object} [runtimeContext] + */ + +/** + * @typedef {Object} DeleteByIdsOptions + * @property {string[]} ids + * @property {Object} [currentUser] + * @property {Object} [transaction] + * @property {Object} [runtimeContext] + */ + +/** + * @typedef {Object} AutocompleteOptions + * @property {string} [query] + * @property {number} [limit] + * @property {number} [offset] + */ + +/** + * @typedef {Object} UpdateOptions + * @property {string} id + * @property {Object} data + * @property {Object} [currentUser] + * @property {Object} [transaction] + * @property {Object} [runtimeContext] + */ + +function assertOptionsObject(options, contractName, methodName) { + if (!options || typeof options !== 'object' || Array.isArray(options)) { + throw new TypeError( + `${contractName}.${methodName} expects an options object`, + ); + } +} + +function assertCreateOptions(options, contractName) { + assertOptionsObject(options, contractName, 'create'); + + if (options.data === undefined) { + throw new TypeError(`${contractName}.create requires { data }`); + } +} + +function assertIdOptions(options, contractName, methodName) { + assertOptionsObject(options, contractName, methodName); + + if (!options.id) { + throw new TypeError(`${contractName}.${methodName} requires { id }`); + } +} + +function assertDeleteByIdsOptions(options, contractName) { + assertOptionsObject(options, contractName, 'deleteByIds'); + + if (!Array.isArray(options.ids)) { + throw new TypeError(`${contractName}.deleteByIds requires { ids }`); + } +} + +function assertAutocompleteOptions(options, contractName) { + assertOptionsObject(options, contractName, 'findAllAutocomplete'); +} + +function assertUpdateOptions(options, contractName) { + assertOptionsObject(options, contractName, 'update'); + + if (!options.id || options.data === undefined) { + throw new TypeError( + `${contractName}.update requires { id, data } in the options object`, + ); + } +} + +module.exports = { + assertAutocompleteOptions, + assertCreateOptions, + assertDeleteByIdsOptions, + assertIdOptions, + assertUpdateOptions, +}; diff --git a/backend/src/db/api/base.api.js b/backend/src/db/api/base.api.js index 7690f2a..e1c0dda 100644 --- a/backend/src/db/api/base.api.js +++ b/backend/src/db/api/base.api.js @@ -2,6 +2,13 @@ const db = require('../models'); const Utils = require('../utils'); const { parse } = require('json2csv'); const { logger } = require('../../utils/logger'); +const { + assertAutocompleteOptions, + assertCreateOptions, + assertDeleteByIdsOptions, + assertIdOptions, + assertUpdateOptions, +} = require('../../contracts/entity-options'); const Sequelize = db.Sequelize; const Op = Sequelize.Op; @@ -147,9 +154,10 @@ class GenericDBApi { return mapped; } - static async create(data, options = {}) { - const currentUser = options.currentUser || { id: null }; - const transaction = options.transaction; + static async create(options) { + assertCreateOptions(options, 'DBApi'); + + const { data, currentUser = { id: null }, transaction } = options; const mappedData = this.getFieldMapping(data); @@ -190,9 +198,17 @@ class GenericDBApi { return this.MODEL.bulkCreate(recordsData, { transaction }); } - static async update(id, data, options = {}) { - const currentUser = options.currentUser || { id: null }; - const transaction = options.transaction; + /** + * @param {Object} options + * @param {string} options.id + * @param {Object} options.data + * @param {Object} [options.currentUser] + * @param {Object} [options.transaction] + */ + static async update(options) { + assertUpdateOptions(options, 'DBApi'); + + const { id, data, currentUser = { id: null }, transaction } = options; const record = await this.MODEL.findByPk(id, { transaction }); @@ -227,13 +243,16 @@ class GenericDBApi { * * Use this when you need to update specific fields without affecting others. * - * @param {string} id - Record ID - * @param {Object} data - Fields to update (only these will be modified) - * @param {Object} options - Options with currentUser and transaction + * @param {Object} options + * @param {string} options.id - Record ID + * @param {Object} options.data - Fields to update + * @param {Object} [options.currentUser] + * @param {Object} [options.transaction] */ - static async partialUpdate(id, data, options = {}) { - const currentUser = options.currentUser || { id: null }; - const transaction = options.transaction; + static async partialUpdate(options) { + assertUpdateOptions(options, 'DBApi'); + + const { id, data, currentUser = { id: null }, transaction } = options; const record = await this.MODEL.findByPk(id, { transaction }); @@ -255,9 +274,10 @@ class GenericDBApi { return record; } - static async deleteByIds(ids, options = {}) { - const currentUser = options.currentUser || { id: null }; - const transaction = options.transaction; + static async deleteByIds(options) { + assertDeleteByIdsOptions(options, 'DBApi'); + + const { ids, currentUser = { id: null }, transaction } = options; const records = await this.MODEL.findAll({ where: { id: { [Op.in]: ids } }, @@ -274,9 +294,10 @@ class GenericDBApi { return records; } - static async remove(id, options = {}) { - const currentUser = options.currentUser || { id: null }; - const transaction = options.transaction; + static async remove(options) { + assertIdOptions(options, 'DBApi', 'remove'); + + const { id, currentUser = { id: null }, transaction } = options; const record = await this.MODEL.findByPk(id, { transaction }); @@ -453,7 +474,11 @@ class GenericDBApi { } } - static async findAllAutocomplete(query, limit, offset) { + static async findAllAutocomplete(options, queryOptions = {}) { + assertAutocompleteOptions(options, 'DBApi'); + + const { query, limit, offset } = options; + const transaction = queryOptions.transaction; let where = {}; if (query) { @@ -474,6 +499,7 @@ class GenericDBApi { limit: limit ? Number(limit) : undefined, offset: offset ? Number(offset) : undefined, order: [[this.AUTOCOMPLETE_FIELD, 'ASC']], + transaction, }); return records.map((record) => ({ diff --git a/backend/src/db/api/element_type_defaults.js b/backend/src/db/api/element_type_defaults.js index 4991701..f8a8d2f 100644 --- a/backend/src/db/api/element_type_defaults.js +++ b/backend/src/db/api/element_type_defaults.js @@ -363,9 +363,9 @@ class Element_type_defaultsDBApi extends GenericDBApi { await this.initializationPromise; } - static async create(data, options = {}) { + static async create(options) { await this.ensureInitialized(); - return super.create(data, options); + return super.create(options); } static async bulkImport(data, options = {}) { @@ -373,19 +373,19 @@ class Element_type_defaultsDBApi extends GenericDBApi { return super.bulkImport(data, options); } - static async update(id, data, options = {}) { + static async update({ id, data, currentUser, transaction, runtimeContext }) { await this.ensureInitialized(); - return super.update(id, data, options); + return super.update({ id, data, currentUser, transaction, runtimeContext }); } - static async deleteByIds(ids, options = {}) { + static async deleteByIds(options) { await this.ensureInitialized(); - return super.deleteByIds(ids, options); + return super.deleteByIds(options); } - static async remove(id, options = {}) { + static async remove(options) { await this.ensureInitialized(); - return super.remove(id, options); + return super.remove(options); } static async findBy(where, options = {}) { @@ -398,9 +398,9 @@ class Element_type_defaultsDBApi extends GenericDBApi { return super.findAll(filter, options); } - static async findAllAutocomplete(query, limit, offset) { + static async findAllAutocomplete(options, queryOptions = {}) { await this.ensureInitialized(); - return super.findAllAutocomplete(query, limit, offset); + return super.findAllAutocomplete(options, queryOptions); } } diff --git a/backend/src/db/api/global_transition_defaults.js b/backend/src/db/api/global_transition_defaults.js index f8974a6..65ced69 100644 --- a/backend/src/db/api/global_transition_defaults.js +++ b/backend/src/db/api/global_transition_defaults.js @@ -134,9 +134,9 @@ class Global_transition_defaultsDBApi extends GenericDBApi { return this.findOne(options); } - static async update(id, data, options = {}) { + static async update({ id, data, currentUser, transaction, runtimeContext }) { await this.ensureInitialized(); - return super.update(id, data, options); + return super.update({ id, data, currentUser, transaction, runtimeContext }); } static async findBy(where, options = {}) { diff --git a/backend/src/db/api/global_ui_control_defaults.js b/backend/src/db/api/global_ui_control_defaults.js index 7430b21..7873d68 100644 --- a/backend/src/db/api/global_ui_control_defaults.js +++ b/backend/src/db/api/global_ui_control_defaults.js @@ -148,9 +148,9 @@ class Global_ui_control_defaultsDBApi extends GenericDBApi { return record.get({ plain: true }); } - static async update(id, data, options = {}) { + static async update({ id, data, currentUser, transaction, runtimeContext }) { await this.ensureInitialized(); - return super.update(id, data, options); + return super.update({ id, data, currentUser, transaction, runtimeContext }); } static async findBy(where, options = {}) { diff --git a/backend/src/db/api/projects.js b/backend/src/db/api/projects.js index e948a39..c9eb18c 100644 --- a/backend/src/db/api/projects.js +++ b/backend/src/db/api/projects.js @@ -115,11 +115,11 @@ class ProjectsDBApi extends GenericDBApi { /** * Create a new project and auto-snapshot global element defaults */ - static async create(data, options = {}) { - const transaction = options.transaction; + static async create(options) { + const { transaction } = options; // Create the project using parent's create - const project = await super.create(data, options); + const project = await super.create(options); // Auto-snapshot global element defaults to the new project // Errors propagate to service layer → transaction rollback → proper error to client diff --git a/backend/src/db/api/tour_pages.js b/backend/src/db/api/tour_pages.js index f735beb..e7824d8 100644 --- a/backend/src/db/api/tour_pages.js +++ b/backend/src/db/api/tour_pages.js @@ -124,9 +124,8 @@ class Tour_pagesDBApi extends GenericDBApi { }; } - static async create(data, options = {}) { - const currentUser = options.currentUser || { id: null }; - const transaction = options.transaction; + static async create(options) { + const { data, currentUser = { id: null }, transaction } = options; const projectId = data.project || data.projectId || null; const record = await this.MODEL.create( diff --git a/backend/src/db/api/users.js b/backend/src/db/api/users.js index f6195c2..3c3c961 100644 --- a/backend/src/db/api/users.js +++ b/backend/src/db/api/users.js @@ -6,6 +6,13 @@ const Utils = require('../utils'); const bcrypt = require('bcrypt'); const config = require('../../config'); const { logger } = require('../../utils/logger'); +const { + assertAutocompleteOptions, + assertCreateOptions, + assertDeleteByIdsOptions, + assertIdOptions, + assertUpdateOptions, +} = require('../../contracts/entity-options'); const Sequelize = db.Sequelize; const Op = Sequelize.Op; @@ -60,40 +67,42 @@ module.exports = class UsersDBApi { ]; } - static async create(data, options) { - const currentUser = (options && options.currentUser) || { id: null }; - const transaction = (options && options.transaction) || undefined; + static async create(options) { + assertCreateOptions(options, 'DBApi'); + + const { data, currentUser = { id: null }, transaction } = options; + const userData = data.data || data; const password = - data.data.password || crypto.randomBytes(20).toString('hex'); + userData.password || crypto.randomBytes(20).toString('hex'); const users = await db.users.create( { - id: data.data.id || undefined, + id: userData.id || undefined, - firstName: data.data.firstName || null, - lastName: data.data.lastName || null, - phoneNumber: data.data.phoneNumber || null, - email: data.data.email || null, - disabled: data.data.disabled || false, + firstName: userData.firstName || null, + lastName: userData.lastName || null, + phoneNumber: userData.phoneNumber || null, + email: userData.email || null, + disabled: userData.disabled || false, password: bcrypt.hashSync(password, config.bcrypt.saltRounds), - emailVerified: data.data.emailVerified || true, + emailVerified: userData.emailVerified || true, - emailVerificationToken: data.data.emailVerificationToken || null, + emailVerificationToken: userData.emailVerificationToken || null, emailVerificationTokenExpiresAt: - data.data.emailVerificationTokenExpiresAt || null, - passwordResetToken: data.data.passwordResetToken || null, + userData.emailVerificationTokenExpiresAt || null, + passwordResetToken: userData.passwordResetToken || null, passwordResetTokenExpiresAt: - data.data.passwordResetTokenExpiresAt || null, - provider: data.data.provider || config.providers.LOCAL, - importHash: data.data.importHash || null, + userData.passwordResetTokenExpiresAt || null, + provider: userData.provider || config.providers.LOCAL, + importHash: userData.importHash || null, createdById: currentUser.id, updatedById: currentUser.id, }, { transaction }, ); - if (!data.data.app_role) { + if (!userData.app_role) { const role = await db.roles.findOne({ where: { name: 'User' }, }); @@ -103,12 +112,12 @@ module.exports = class UsersDBApi { }); } } else { - await users.setApp_role(data.data.app_role || null, { + await users.setApp_role(userData.app_role || null, { transaction, }); } - await users.setCustom_permissions(data.data.custom_permissions || [], { + await users.setCustom_permissions(userData.custom_permissions || [], { transaction, }); @@ -118,7 +127,7 @@ module.exports = class UsersDBApi { belongsToColumn: 'avatar', belongsToId: users.id, }, - data.data.avatar, + userData.avatar, options, ); @@ -177,9 +186,16 @@ module.exports = class UsersDBApi { return users; } - static async update(id, data, options) { - const currentUser = (options && options.currentUser) || { id: null }; - const transaction = (options && options.transaction) || undefined; + static async update(updateOptions) { + assertUpdateOptions(updateOptions, 'DBApi'); + const { + id, + data, + currentUser = { id: null }, + transaction, + runtimeContext, + } = updateOptions; + const dbOptions = { currentUser, transaction, runtimeContext }; const users = await db.users.findByPk(id, { transaction }); @@ -272,15 +288,15 @@ module.exports = class UsersDBApi { belongsToId: users.id, }, data.avatar, - options, + dbOptions, ); return users; } - static async deleteByIds(ids, options) { - const currentUser = (options && options.currentUser) || { id: null }; - const transaction = (options && options.transaction) || undefined; + static async deleteByIds(options) { + assertDeleteByIdsOptions(options, 'DBApi'); + const { ids, currentUser = { id: null }, transaction } = options; const users = await db.users.findAll({ where: { @@ -291,23 +307,21 @@ module.exports = class UsersDBApi { transaction, }); - await db.sequelize.transaction(async (transaction) => { - for (const record of users) { - await record.update({ deletedBy: currentUser.id }, { transaction }); - } - for (const record of users) { - await record.destroy({ transaction }); - } - }); + for (const record of users) { + await record.update({ deletedBy: currentUser.id }, { transaction }); + } + for (const record of users) { + await record.destroy({ transaction }); + } return users; } - static async remove(id, options) { - const currentUser = (options && options.currentUser) || { id: null }; - const transaction = (options && options.transaction) || undefined; + static async remove(options) { + assertIdOptions(options, 'DBApi', 'remove'); + const { id, currentUser = { id: null }, transaction } = options; - const users = await db.users.findByPk(id, options); + const users = await db.users.findByPk(id, { transaction }); await users.update( { @@ -694,7 +708,10 @@ module.exports = class UsersDBApi { } } - static async findAllAutocomplete(query, limit, offset) { + static async findAllAutocomplete(options, queryOptions = {}) { + assertAutocompleteOptions(options, 'DBApi'); + const { query, limit, offset } = options; + const transaction = queryOptions.transaction; let where = {}; if (query) { @@ -713,6 +730,7 @@ module.exports = class UsersDBApi { limit: limit ? Number(limit) : undefined, offset: offset ? Number(offset) : undefined, orderBy: [['firstName', 'ASC']], + transaction, }); return records.map((record) => ({ diff --git a/backend/src/db/migrations/20260628000006-backfill-page-elements-role-permissions.js b/backend/src/db/migrations/20260628000006-backfill-page-elements-role-permissions.js new file mode 100644 index 0000000..ab25cba --- /dev/null +++ b/backend/src/db/migrations/20260628000006-backfill-page-elements-role-permissions.js @@ -0,0 +1,67 @@ +'use strict'; + +const ROLE_PERMISSIONS = [ + ['Administrator', 'CREATE_PAGE_ELEMENTS'], + ['Administrator', 'READ_PAGE_ELEMENTS'], + ['Administrator', 'UPDATE_PAGE_ELEMENTS'], + ['Administrator', 'DELETE_PAGE_ELEMENTS'], + ['Platform Owner', 'CREATE_PAGE_ELEMENTS'], + ['Platform Owner', 'READ_PAGE_ELEMENTS'], + ['Platform Owner', 'UPDATE_PAGE_ELEMENTS'], + ['Platform Owner', 'DELETE_PAGE_ELEMENTS'], + ['Account Manager', 'CREATE_PAGE_ELEMENTS'], + ['Account Manager', 'READ_PAGE_ELEMENTS'], + ['Account Manager', 'UPDATE_PAGE_ELEMENTS'], + ['Tour Designer', 'CREATE_PAGE_ELEMENTS'], + ['Tour Designer', 'READ_PAGE_ELEMENTS'], + ['Tour Designer', 'UPDATE_PAGE_ELEMENTS'], + ['Content Reviewer', 'READ_PAGE_ELEMENTS'], + ['Content Reviewer', 'UPDATE_PAGE_ELEMENTS'], + ['Analytics Viewer', 'READ_PAGE_ELEMENTS'], +]; + +module.exports = { + async up(queryInterface) { + await queryInterface.sequelize.query( + ` + WITH expected(role_name, permission_name) AS ( + VALUES ${ROLE_PERMISSIONS.map(() => '(?, ?)').join(', ')} + ) + INSERT INTO "rolesPermissionsPermissions" + ("createdAt", "updatedAt", "roles_permissionsId", "permissionId") + SELECT NOW(), NOW(), r.id, p.id + FROM expected e + JOIN roles r ON r.name = e.role_name + JOIN permissions p ON p.name = e.permission_name + WHERE NOT EXISTS ( + SELECT 1 + FROM "rolesPermissionsPermissions" rp + WHERE rp."roles_permissionsId" = r.id + AND rp."permissionId" = p.id + ) + `, + { + replacements: ROLE_PERMISSIONS.flat(), + }, + ); + }, + + async down(queryInterface) { + await queryInterface.sequelize.query( + ` + WITH expected(role_name, permission_name) AS ( + VALUES ${ROLE_PERMISSIONS.map(() => '(?, ?)').join(', ')} + ) + DELETE FROM "rolesPermissionsPermissions" rp + USING expected e, roles r, permissions p + WHERE rp."roles_permissionsId" = r.id + AND rp."permissionId" = p.id + AND r.name = e.role_name + AND p.name = e.permission_name + `, + { + replacements: ROLE_PERMISSIONS.flat(), + }, + ); + }, +}; diff --git a/backend/src/factories/router.factory.js b/backend/src/factories/router.factory.js index c5db8a2..f25ce72 100644 --- a/backend/src/factories/router.factory.js +++ b/backend/src/factories/router.factory.js @@ -68,12 +68,13 @@ function createEntityRouter(entityName, Service, DBApi, options = {}) { req.headers.referer || `${req.protocol}://${req.hostname}${req.originalUrl}`; const link = new URL(referer); - const payload = await Service.create( - req.body.data, - req.currentUser, - true, - link.origin, - ); + const payload = await Service.create({ + data: req.body.data, + currentUser: req.currentUser, + runtimeContext: req.runtimeContext, + sendInvitationEmails: true, + host: link.origin, + }); res.status(200).send(payload); }), ); @@ -95,7 +96,12 @@ function createEntityRouter(entityName, Service, DBApi, options = {}) { validateRequest(schemaFor('update')), wrapAsync(async (req, res) => { assertRouteIdMatchesBody(req); - await Service.update(req.body.data, req.params.id, req.currentUser); + await Service.update({ + id: req.params.id, + data: req.body.data, + currentUser: req.currentUser, + runtimeContext: req.runtimeContext, + }); res.status(200).send(true); }), ); @@ -104,7 +110,11 @@ function createEntityRouter(entityName, Service, DBApi, options = {}) { '/:id', validateRequest(schemaFor('remove')), wrapAsync(async (req, res) => { - await Service.remove(req.params.id, req.currentUser); + await Service.remove({ + id: req.params.id, + currentUser: req.currentUser, + runtimeContext: req.runtimeContext, + }); res.status(200).send(true); }), ); @@ -113,7 +123,11 @@ function createEntityRouter(entityName, Service, DBApi, options = {}) { '/deleteByIds', validateRequest(schemaFor('deleteByIds')), wrapAsync(async (req, res) => { - await Service.deleteByIds(req.body.data, req.currentUser); + await Service.deleteByIds({ + ids: req.body.data, + currentUser: req.currentUser, + runtimeContext: req.runtimeContext, + }); res.status(200).send(true); }), ); @@ -174,11 +188,11 @@ function createEntityRouter(entityName, Service, DBApi, options = {}) { defaultLimit: 20, maxLimit: MAX_AUTOCOMPLETE_LIMIT, }); - const payload = await DBApi.findAllAutocomplete( - req.query.query, + const payload = await DBApi.findAllAutocomplete({ + query: req.query.query, limit, - req.query.offset, - ); + offset: req.query.offset, + }); res.status(200).send(payload); }), ); diff --git a/backend/src/factories/service.factory.js b/backend/src/factories/service.factory.js index 80b866c..73feefc 100644 --- a/backend/src/factories/service.factory.js +++ b/backend/src/factories/service.factory.js @@ -3,19 +3,41 @@ const processFile = require('../middlewares/upload'); const ValidationError = require('../services/notifications/errors/validation'); const csv = require('csv-parser'); const stream = require('stream'); +const { + assertCreateOptions, + assertDeleteByIdsOptions, + assertIdOptions, + assertUpdateOptions, +} = require('../contracts/entity-options'); function createEntityService(DBApi, options = {}) { const entityName = options.entityName || 'Entity'; return class GenericService { - static async create(data, currentUser) { - const transaction = await db.sequelize.transaction(); + static async create(options) { + assertCreateOptions(options, 'Service'); + + const { + data, + currentUser, + transaction: externalTransaction, + runtimeContext, + } = options; + const transaction = + externalTransaction || (await db.sequelize.transaction()); + const ownsTransaction = !externalTransaction; + try { - const record = await DBApi.create(data, { currentUser, transaction }); - await transaction.commit(); + const record = await DBApi.create({ + data, + currentUser, + transaction, + runtimeContext, + }); + if (ownsTransaction) await transaction.commit(); return record; } catch (error) { - await transaction.rollback(); + if (ownsTransaction) await transaction.rollback(); throw error; } } @@ -52,45 +74,103 @@ function createEntityService(DBApi, options = {}) { } } - static async update(data, id, currentUser) { - const transaction = await db.sequelize.transaction(); + /** + * @param {Object} options + * @param {string} options.id + * @param {Object} options.data + * @param {Object} [options.currentUser] + * @param {Object} [options.transaction] + * @param {Object} [options.runtimeContext] + */ + 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 record = await DBApi.findBy({ id }, { transaction }); + const record = await DBApi.findBy( + { id }, + { transaction, runtimeContext }, + ); if (!record) { throw new ValidationError(`${entityName}NotFound`); } - const updated = await DBApi.update(id, data, { + const updated = await DBApi.update({ + id, + data, currentUser, transaction, + runtimeContext, }); - await transaction.commit(); + if (ownsTransaction) await transaction.commit(); return updated; } catch (error) { - await transaction.rollback(); + if (ownsTransaction) await transaction.rollback(); throw error; } } - static async deleteByIds(ids, currentUser) { - const transaction = await db.sequelize.transaction(); + static async deleteByIds(options) { + assertDeleteByIdsOptions(options, 'Service'); + + const { + ids, + currentUser, + transaction: externalTransaction, + runtimeContext, + } = options; + const transaction = + externalTransaction || (await db.sequelize.transaction()); + const ownsTransaction = !externalTransaction; + try { - await DBApi.deleteByIds(ids, { currentUser, transaction }); - await transaction.commit(); + await DBApi.deleteByIds({ + ids, + currentUser, + transaction, + runtimeContext, + }); + if (ownsTransaction) await transaction.commit(); } catch (error) { - await transaction.rollback(); + if (ownsTransaction) await transaction.rollback(); throw error; } } - static async remove(id, currentUser) { - const transaction = await db.sequelize.transaction(); + static async remove(options) { + assertIdOptions(options, 'Service', 'remove'); + + const { + id, + currentUser, + transaction: externalTransaction, + runtimeContext, + } = options; + const transaction = + externalTransaction || (await db.sequelize.transaction()); + const ownsTransaction = !externalTransaction; + try { - await DBApi.remove(id, { currentUser, transaction }); - await transaction.commit(); + await DBApi.remove({ + id, + currentUser, + transaction, + runtimeContext, + }); + if (ownsTransaction) await transaction.commit(); } catch (error) { - await transaction.rollback(); + if (ownsTransaction) await transaction.rollback(); throw error; } } diff --git a/backend/src/routes/global_transition_defaults.js b/backend/src/routes/global_transition_defaults.js index 009a028..8579216 100644 --- a/backend/src/routes/global_transition_defaults.js +++ b/backend/src/routes/global_transition_defaults.js @@ -123,11 +123,12 @@ router.put( jwtAuth, wrapAsync(async (req, res) => { assertRouteIdMatchesBody(req); - await Global_transition_defaultsService.update( - req.body.data, - req.params.id, - req.currentUser, - ); + await Global_transition_defaultsService.update({ + id: req.params.id, + data: req.body.data, + currentUser: req.currentUser, + runtimeContext: req.runtimeContext, + }); res.status(200).send(true); }), ); diff --git a/backend/src/routes/global_ui_control_defaults.js b/backend/src/routes/global_ui_control_defaults.js index e024639..6c056d0 100644 --- a/backend/src/routes/global_ui_control_defaults.js +++ b/backend/src/routes/global_ui_control_defaults.js @@ -44,11 +44,12 @@ router.put( '/:id', jwtAuth, wrapAsync(async (req, res) => { - await Global_ui_control_defaultsService.update( - req.body.data, - req.params.id, - req.currentUser, - ); + await Global_ui_control_defaultsService.update({ + id: req.params.id, + data: req.body.data, + currentUser: req.currentUser, + runtimeContext: req.runtimeContext, + }); const payload = await Global_ui_control_defaultsDBApi.findOne(); res.status(200).send(payload); }), diff --git a/backend/src/routes/project_transition_settings.js b/backend/src/routes/project_transition_settings.js index 3c3e29e..4e2aab1 100644 --- a/backend/src/routes/project_transition_settings.js +++ b/backend/src/routes/project_transition_settings.js @@ -284,10 +284,11 @@ router.delete( ); if (settings) { - await Project_transition_settingsService.remove( - settings.id, - req.currentUser, - ); + await Project_transition_settingsService.remove({ + id: settings.id, + currentUser: req.currentUser, + runtimeContext: req.runtimeContext, + }); } res.status(200).send({ success: true }); @@ -340,10 +341,11 @@ router.post( '/', jwtAuth, wrapAsync(async (req, res) => { - const payload = await Project_transition_settingsService.create( - req.body.data, - req.currentUser, - ); + const payload = await Project_transition_settingsService.create({ + data: req.body.data, + currentUser: req.currentUser, + runtimeContext: req.runtimeContext, + }); res.status(200).send(payload); }), ); @@ -415,11 +417,12 @@ router.put( jwtAuth, wrapAsync(async (req, res) => { assertRouteIdMatchesBody(req); - await Project_transition_settingsService.update( - req.body.data, - req.params.id, - req.currentUser, - ); + await Project_transition_settingsService.update({ + id: req.params.id, + data: req.body.data, + currentUser: req.currentUser, + runtimeContext: req.runtimeContext, + }); res.status(200).send(true); }), ); @@ -447,10 +450,11 @@ router.delete( '/:id', jwtAuth, wrapAsync(async (req, res) => { - await Project_transition_settingsService.remove( - req.params.id, - req.currentUser, - ); + await Project_transition_settingsService.remove({ + id: req.params.id, + currentUser: req.currentUser, + runtimeContext: req.runtimeContext, + }); res.status(200).send(true); }), ); diff --git a/backend/src/routes/project_ui_control_settings.js b/backend/src/routes/project_ui_control_settings.js index 0866670..b3365db 100644 --- a/backend/src/routes/project_ui_control_settings.js +++ b/backend/src/routes/project_ui_control_settings.js @@ -161,10 +161,11 @@ router.delete( ); if (settings) { - await Project_ui_control_settingsService.remove( - settings.id, - req.currentUser, - ); + await Project_ui_control_settingsService.remove({ + id: settings.id, + currentUser: req.currentUser, + runtimeContext: req.runtimeContext, + }); } res.status(200).send({ success: true }); diff --git a/backend/src/routes/tour_pages.js b/backend/src/routes/tour_pages.js index 598ddde..a6f6f28 100644 --- a/backend/src/routes/tour_pages.js +++ b/backend/src/routes/tour_pages.js @@ -175,12 +175,13 @@ router.post( req.headers.referer || `${req.protocol}://${req.hostname}${req.originalUrl}`; const link = new URL(referer); - const payload = await Tour_pagesService.create( - req.body.data, - req.currentUser, - true, - link.origin, - ); + const payload = await Tour_pagesService.create({ + data: req.body.data, + currentUser: req.currentUser, + runtimeContext: req.runtimeContext, + sendInvitationEmails: true, + host: link.origin, + }); res.status(200).send(payload); }), ); @@ -231,11 +232,12 @@ router.put( validateRequest(tourPageSchemas.update), wrapAsync(async (req, res) => { assertRouteIdMatchesBody(req); - await Tour_pagesService.update( - req.body.data, - req.params.id, - req.currentUser, - ); + await Tour_pagesService.update({ + id: req.params.id, + data: req.body.data, + currentUser: req.currentUser, + runtimeContext: req.runtimeContext, + }); res.status(200).send(true); }), ); @@ -245,7 +247,11 @@ router.delete( '/:id', validateRequest(crudSchemas.remove), wrapAsync(async (req, res) => { - await Tour_pagesService.remove(req.params.id, req.currentUser); + await Tour_pagesService.remove({ + id: req.params.id, + currentUser: req.currentUser, + runtimeContext: req.runtimeContext, + }); res.status(200).send(true); }), ); @@ -255,7 +261,11 @@ router.post( '/deleteByIds', validateRequest(crudSchemas.deleteByIds), wrapAsync(async (req, res) => { - await Tour_pagesService.deleteByIds(req.body.data, req.currentUser); + await Tour_pagesService.deleteByIds({ + ids: req.body.data, + currentUser: req.currentUser, + runtimeContext: req.runtimeContext, + }); res.status(200).send(true); }), ); @@ -334,11 +344,11 @@ router.get( '/autocomplete', validateRequest(crudSchemas.autocomplete), wrapAsync(async (req, res) => { - const payload = await Tour_pagesDBApi.findAllAutocomplete( - req.query.query, - req.query.limit, - req.query.offset, - ); + const payload = await Tour_pagesDBApi.findAllAutocomplete({ + query: req.query.query, + limit: req.query.limit, + offset: req.query.offset, + }); res.status(200).send(payload); }), ); diff --git a/backend/src/services/assets.js b/backend/src/services/assets.js index 865f0e6..6cf9c61 100644 --- a/backend/src/services/assets.js +++ b/backend/src/services/assets.js @@ -1,5 +1,9 @@ const AssetsDBApi = require('../db/api/assets'); const { createEntityService } = require('../factories/service.factory'); +const { + assertCreateOptions, + assertUpdateOptions, +} = require('../contracts/entity-options'); const ValidationError = require('./notifications/errors/validation'); const { downloadToTempFile } = require('./file'); const { probeMediaMetadata } = require('./videoProcessing'); @@ -196,7 +200,11 @@ class AssetsService extends BaseService { /** * Create asset with MIME type validation, embed URL validation, and video pre-processing */ - static async create(data, currentUser) { + static async create(options) { + assertCreateOptions(options, 'Service'); + let { data } = options; + const { currentUser, transaction, runtimeContext } = options; + // Validate asset_type and mime_type match const assetType = data.asset_type; const mimeType = data.mime_type; @@ -216,7 +224,12 @@ class AssetsService extends BaseService { data = await AssetsService.enrichStoredMediaMetadata(data); // Call parent create - const asset = await super.create(data, currentUser); + const asset = await super.create({ + data, + currentUser, + transaction, + runtimeContext, + }); // Note: Reversed video generation is handled by tour_pages.js when the video // is assigned to a navigation element. This avoids unnecessary processing @@ -228,8 +241,15 @@ class AssetsService extends BaseService { /** * Update asset with MIME type validation and embed URL validation */ - static async update(data, id, currentUser) { - const existingAsset = await AssetsDBApi.findBy({ id }); + static async update(options) { + assertUpdateOptions(options, 'Service'); + let { data } = options; + const { id, currentUser, transaction, runtimeContext } = options; + + const existingAsset = await AssetsDBApi.findBy( + { id }, + { transaction, runtimeContext }, + ); // If updating asset_type or mime_type, validate they match if (data.asset_type || data.mime_type) { @@ -258,7 +278,13 @@ class AssetsService extends BaseService { }); // Call parent update - return super.update(data, id, currentUser); + return super.update({ + id, + data, + currentUser, + transaction, + runtimeContext, + }); } } diff --git a/backend/src/services/auth.js b/backend/src/services/auth.js index aeb0549..53961d2 100644 --- a/backend/src/services/auth.js +++ b/backend/src/services/auth.js @@ -155,7 +155,9 @@ class Auth { try { await UsersDBApi.findBy({ id: currentUser.id }, { transaction }); - await UsersDBApi.update(currentUser.id, data, { + await UsersDBApi.update({ + id: currentUser.id, + data, currentUser, transaction, }); diff --git a/backend/src/services/project_audio_tracks.js b/backend/src/services/project_audio_tracks.js index 7b9dbea..c98fa76 100644 --- a/backend/src/services/project_audio_tracks.js +++ b/backend/src/services/project_audio_tracks.js @@ -2,22 +2,40 @@ const db = require('../db/models'); const Project_audio_tracksDBApi = require('../db/api/project_audio_tracks'); const processFile = require('../middlewares/upload'); const ValidationError = require('./notifications/errors/validation'); +const { + assertCreateOptions, + assertDeleteByIdsOptions, + assertIdOptions, + assertUpdateOptions, +} = require('../contracts/entity-options'); const csv = require('csv-parser'); const stream = require('stream'); module.exports = class Project_audio_tracksService { - static async create(data, currentUser) { - const transaction = await db.sequelize.transaction(); + static async create(options) { + assertCreateOptions(options, 'Service'); + const { + data, + currentUser, + transaction: externalTransaction, + runtimeContext, + } = options; + const transaction = + externalTransaction || (await db.sequelize.transaction()); + const ownsTransaction = !externalTransaction; + try { - const createdTrack = await Project_audio_tracksDBApi.create(data, { + const createdTrack = await Project_audio_tracksDBApi.create({ + data, currentUser, transaction, + runtimeContext, }); - await transaction.commit(); + if (ownsTransaction) await transaction.commit(); return createdTrack; } catch (error) { - await transaction.rollback(); + if (ownsTransaction) await transaction.rollback(); throw error; } } @@ -57,12 +75,23 @@ module.exports = class Project_audio_tracksService { } } - static async update(data, id, currentUser) { - const transaction = await db.sequelize.transaction(); + 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 { let project_audio_tracks = await Project_audio_tracksDBApi.findBy( { id }, - { transaction }, + { transaction, runtimeContext }, ); if (!project_audio_tracks) { @@ -70,47 +99,72 @@ module.exports = class Project_audio_tracksService { } const updatedProject_audio_tracks = - await Project_audio_tracksDBApi.update(id, data, { + await Project_audio_tracksDBApi.update({ + id, + data, currentUser, transaction, + runtimeContext, }); - await transaction.commit(); + if (ownsTransaction) await transaction.commit(); return updatedProject_audio_tracks; } catch (error) { - await transaction.rollback(); + if (ownsTransaction) await transaction.rollback(); throw error; } } - static async deleteByIds(ids, currentUser) { - const transaction = await db.sequelize.transaction(); + static async deleteByIds(options) { + assertDeleteByIdsOptions(options, 'Service'); + const { + ids, + currentUser, + transaction: externalTransaction, + runtimeContext, + } = options; + const transaction = + externalTransaction || (await db.sequelize.transaction()); + const ownsTransaction = !externalTransaction; try { - await Project_audio_tracksDBApi.deleteByIds(ids, { + await Project_audio_tracksDBApi.deleteByIds({ + ids, currentUser, transaction, + runtimeContext, }); - await transaction.commit(); + if (ownsTransaction) await transaction.commit(); } catch (error) { - await transaction.rollback(); + if (ownsTransaction) await transaction.rollback(); throw error; } } - static async remove(id, currentUser) { - const transaction = await db.sequelize.transaction(); + static async remove(options) { + assertIdOptions(options, 'Service', 'remove'); + const { + id, + currentUser, + transaction: externalTransaction, + runtimeContext, + } = options; + const transaction = + externalTransaction || (await db.sequelize.transaction()); + const ownsTransaction = !externalTransaction; try { - await Project_audio_tracksDBApi.remove(id, { + await Project_audio_tracksDBApi.remove({ + id, currentUser, transaction, + runtimeContext, }); - await transaction.commit(); + if (ownsTransaction) await transaction.commit(); } catch (error) { - await transaction.rollback(); + if (ownsTransaction) await transaction.rollback(); throw error; } } diff --git a/backend/src/services/project_transition_settings.js b/backend/src/services/project_transition_settings.js index ae832fe..2f8401e 100644 --- a/backend/src/services/project_transition_settings.js +++ b/backend/src/services/project_transition_settings.js @@ -2,25 +2,40 @@ const db = require('../db/models'); const Project_transition_settingsDBApi = require('../db/api/project_transition_settings'); const processFile = require('../middlewares/upload'); const ValidationError = require('./notifications/errors/validation'); +const { + assertCreateOptions, + assertDeleteByIdsOptions, + assertIdOptions, + assertUpdateOptions, +} = require('../contracts/entity-options'); const csv = require('csv-parser'); const stream = require('stream'); module.exports = class Project_transition_settingsService { - static async create(data, currentUser) { - const transaction = await db.sequelize.transaction(); - try { - const createdRecord = await Project_transition_settingsDBApi.create( - data, - { - currentUser, - transaction, - }, - ); + static async create(options) { + assertCreateOptions(options, 'Service'); + const { + data, + currentUser, + transaction: externalTransaction, + runtimeContext, + } = options; + const transaction = + externalTransaction || (await db.sequelize.transaction()); + const ownsTransaction = !externalTransaction; - await transaction.commit(); + try { + const createdRecord = await Project_transition_settingsDBApi.create({ + data, + currentUser, + transaction, + runtimeContext, + }); + + if (ownsTransaction) await transaction.commit(); return createdRecord; } catch (error) { - await transaction.rollback(); + if (ownsTransaction) await transaction.rollback(); throw error; } } @@ -60,63 +75,95 @@ module.exports = class Project_transition_settingsService { } } - static async update(data, id, currentUser) { - const transaction = await db.sequelize.transaction(); + 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 { let record = await Project_transition_settingsDBApi.findBy( { id }, - { transaction }, + { transaction, runtimeContext }, ); if (!record) { throw new ValidationError('project_transition_settingsNotFound'); } - const updatedRecord = await Project_transition_settingsDBApi.update( + const updatedRecord = await Project_transition_settingsDBApi.update({ id, data, - { - currentUser, - transaction, - }, - ); + currentUser, + transaction, + runtimeContext, + }); - await transaction.commit(); + if (ownsTransaction) await transaction.commit(); return updatedRecord; } catch (error) { - await transaction.rollback(); + if (ownsTransaction) await transaction.rollback(); throw error; } } - static async deleteByIds(ids, currentUser) { - const transaction = await db.sequelize.transaction(); + static async deleteByIds(options) { + assertDeleteByIdsOptions(options, 'Service'); + const { + ids, + currentUser, + transaction: externalTransaction, + runtimeContext, + } = options; + const transaction = + externalTransaction || (await db.sequelize.transaction()); + const ownsTransaction = !externalTransaction; try { - await Project_transition_settingsDBApi.deleteByIds(ids, { + await Project_transition_settingsDBApi.deleteByIds({ + ids, currentUser, transaction, + runtimeContext, }); - await transaction.commit(); + if (ownsTransaction) await transaction.commit(); } catch (error) { - await transaction.rollback(); + if (ownsTransaction) await transaction.rollback(); throw error; } } - static async remove(id, currentUser) { - const transaction = await db.sequelize.transaction(); + static async remove(options) { + assertIdOptions(options, 'Service', 'remove'); + const { + id, + currentUser, + transaction: externalTransaction, + runtimeContext, + } = options; + const transaction = + externalTransaction || (await db.sequelize.transaction()); + const ownsTransaction = !externalTransaction; try { - await Project_transition_settingsDBApi.remove(id, { + await Project_transition_settingsDBApi.remove({ + id, currentUser, transaction, + runtimeContext, }); - await transaction.commit(); + if (ownsTransaction) await transaction.commit(); } catch (error) { - await transaction.rollback(); + if (ownsTransaction) await transaction.rollback(); throw error; } } diff --git a/backend/src/services/project_ui_control_settings.js b/backend/src/services/project_ui_control_settings.js index c7a2c5e..152a1a0 100644 --- a/backend/src/services/project_ui_control_settings.js +++ b/backend/src/services/project_ui_control_settings.js @@ -1,6 +1,7 @@ const db = require('../db/models'); const Project_ui_control_settingsDBApi = require('../db/api/project_ui_control_settings'); const ValidationError = require('./notifications/errors/validation'); +const { assertIdOptions } = require('../contracts/entity-options'); module.exports = class Project_ui_control_settingsService { static async findByProjectAndEnvironment( @@ -34,27 +35,38 @@ module.exports = class Project_ui_control_settingsService { } } - static async remove(id, currentUser) { - const transaction = await db.sequelize.transaction(); + static async remove(options) { + assertIdOptions(options, 'Service', 'remove'); + const { + id, + currentUser, + transaction: externalTransaction, + runtimeContext, + } = options; + const transaction = + externalTransaction || (await db.sequelize.transaction()); + const ownsTransaction = !externalTransaction; try { const record = await Project_ui_control_settingsDBApi.findBy( { id }, - { transaction }, + { transaction, runtimeContext }, ); if (!record) { throw new ValidationError('project_ui_control_settingsNotFound'); } - await Project_ui_control_settingsDBApi.remove(id, { + await Project_ui_control_settingsDBApi.remove({ + id, currentUser, transaction, + runtimeContext, }); - await transaction.commit(); + if (ownsTransaction) await transaction.commit(); } catch (error) { - await transaction.rollback(); + if (ownsTransaction) await transaction.rollback(); throw error; } } diff --git a/backend/src/services/projects.js b/backend/src/services/projects.js index fa10818..b919c5c 100644 --- a/backend/src/services/projects.js +++ b/backend/src/services/projects.js @@ -3,6 +3,10 @@ const { v4: uuidv4 } = require('uuid'); const db = require('../db/models'); const ProjectsDBApi = require('../db/api/projects'); const { createEntityService } = require('../factories/service.factory'); +const { + assertCreateOptions, + assertUpdateOptions, +} = require('../contracts/entity-options'); const ValidationError = require('./notifications/errors/validation'); const FileService = require('./file'); const { logger } = require('../utils/logger'); @@ -147,8 +151,18 @@ class ProjectsService extends BaseProjectsService { /** * Create project with slug validation */ - static async create(data, currentUser) { - const transaction = await db.sequelize.transaction(); + static async create(options) { + assertCreateOptions(options, 'Service'); + const { + data, + currentUser, + transaction: externalTransaction, + runtimeContext, + } = options; + const transaction = + externalTransaction || (await db.sequelize.transaction()); + const ownsTransaction = !externalTransaction; + try { if (data.slug) { data.slug = await ProjectsService.validateSlugUniqueness( @@ -158,15 +172,17 @@ class ProjectsService extends BaseProjectsService { ); } - const project = await ProjectsDBApi.create(data, { + const project = await ProjectsDBApi.create({ + data, currentUser, transaction, + runtimeContext, }); - await transaction.commit(); + if (ownsTransaction) await transaction.commit(); return project; } catch (error) { - await transaction.rollback(); + if (ownsTransaction) await transaction.rollback(); throw error; } } @@ -174,10 +190,24 @@ class ProjectsService extends BaseProjectsService { /** * Update project with slug validation */ - static async update(data, id, currentUser) { - const transaction = await db.sequelize.transaction(); + 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 project = await ProjectsDBApi.findBy({ id }, { transaction }); + const project = await ProjectsDBApi.findBy( + { id }, + { transaction, runtimeContext }, + ); if (!project) { throw new ValidationError('projectsNotFound'); @@ -191,15 +221,18 @@ class ProjectsService extends BaseProjectsService { ); } - const updated = await ProjectsDBApi.update(id, data, { + const updated = await ProjectsDBApi.update({ + id, + data, currentUser, transaction, + runtimeContext, }); - await transaction.commit(); + if (ownsTransaction) await transaction.commit(); return updated; } catch (error) { - await transaction.rollback(); + if (ownsTransaction) await transaction.rollback(); throw error; } } @@ -238,8 +271,8 @@ class ProjectsService extends BaseProjectsService { transaction, ); - const clonedProject = await ProjectsDBApi.create( - { + const clonedProject = await ProjectsDBApi.create({ + data: { name: `${sourceProject.name} (Copy)`, slug: uniqueSlug, description: sourceProject.description, @@ -249,8 +282,9 @@ class ProjectsService extends BaseProjectsService { design_width: sourceProject.design_width, design_height: sourceProject.design_height, }, - { currentUser, transaction }, - ); + currentUser, + transaction, + }); // ============================================ // Phase B: Collect all copy operations diff --git a/backend/src/services/roles.js b/backend/src/services/roles.js index 56cabd4..5c14b94 100644 --- a/backend/src/services/roles.js +++ b/backend/src/services/roles.js @@ -8,6 +8,12 @@ const config = require('../config'); const stream = require('stream'); const { validateReadOnlySql } = require('../utils/sqlValidator'); const { logger } = require('../utils/logger'); +const { + assertCreateOptions, + assertDeleteByIdsOptions, + assertIdOptions, + assertUpdateOptions, +} = require('../contracts/entity-options'); const WIDGET_SQL_MAX_LENGTH = 5000; const WIDGET_SQL_MAX_ROWS = 1000; @@ -48,20 +54,32 @@ module.exports = class RolesService { } } - static async create(data, currentUser) { - const transaction = await db.sequelize.transaction(); + static async create(options) { + assertCreateOptions(options, 'Service'); + const { + data, + currentUser, + transaction: externalTransaction, + runtimeContext, + } = options; + const transaction = + externalTransaction || (await db.sequelize.transaction()); + const ownsTransaction = !externalTransaction; + try { this.assertPublicRoleHasNoPermissions(data); - const createdRole = await RolesDBApi.create(data, { + const createdRole = await RolesDBApi.create({ + data, currentUser, transaction, + runtimeContext, }); - await transaction.commit(); + if (ownsTransaction) await transaction.commit(); return createdRole; } catch (error) { - await transaction.rollback(); + if (ownsTransaction) await transaction.rollback(); throw error; } } @@ -104,10 +122,24 @@ module.exports = class RolesService { } } - static async update(data, id, currentUser) { - const transaction = await db.sequelize.transaction(); + 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 { - let roles = await RolesDBApi.findBy({ id }, { transaction }); + let roles = await RolesDBApi.findBy( + { id }, + { transaction, runtimeContext }, + ); if (!roles) { throw new ValidationError('rolesNotFound'); @@ -115,47 +147,72 @@ module.exports = class RolesService { this.assertPublicRoleHasNoPermissions(data, roles); - const updatedRoles = await RolesDBApi.update(id, data, { + const updatedRoles = await RolesDBApi.update({ + id, + data, currentUser, transaction, + runtimeContext, }); - await transaction.commit(); + if (ownsTransaction) await transaction.commit(); return updatedRoles; } catch (error) { - await transaction.rollback(); + if (ownsTransaction) await transaction.rollback(); throw error; } } - static async deleteByIds(ids, currentUser) { - const transaction = await db.sequelize.transaction(); + static async deleteByIds(options) { + assertDeleteByIdsOptions(options, 'Service'); + const { + ids, + currentUser, + transaction: externalTransaction, + runtimeContext, + } = options; + const transaction = + externalTransaction || (await db.sequelize.transaction()); + const ownsTransaction = !externalTransaction; try { - await RolesDBApi.deleteByIds(ids, { + await RolesDBApi.deleteByIds({ + ids, currentUser, transaction, + runtimeContext, }); - await transaction.commit(); + if (ownsTransaction) await transaction.commit(); } catch (error) { - await transaction.rollback(); + if (ownsTransaction) await transaction.rollback(); throw error; } } - static async remove(id, currentUser) { - const transaction = await db.sequelize.transaction(); + static async remove(options) { + assertIdOptions(options, 'Service', 'remove'); + const { + id, + currentUser, + transaction: externalTransaction, + runtimeContext, + } = options; + const transaction = + externalTransaction || (await db.sequelize.transaction()); + const ownsTransaction = !externalTransaction; try { - await RolesDBApi.remove(id, { + await RolesDBApi.remove({ + id, currentUser, transaction, + runtimeContext, }); - await transaction.commit(); + if (ownsTransaction) await transaction.commit(); } catch (error) { - await transaction.rollback(); + if (ownsTransaction) await transaction.rollback(); throw error; } } @@ -197,18 +254,16 @@ module.exports = class RolesService { customization[key] = [widgetId]; } - const newRole = await RolesDBApi.update( - role.id, - { + const newRole = await RolesDBApi.update({ + id: role.id, + data: { role_customization: JSON.stringify(customization), name: role.name, permissions: role.permissions, }, - { - currentUser, - transaction, - }, - ); + currentUser, + transaction, + }); await transaction.commit(); @@ -249,18 +304,16 @@ module.exports = class RolesService { `${config.flHost}/${config.project_uuid}/project_customization_widgets/${infoId}.json`, ); try { - const result = await RolesDBApi.update( - role.id, - { + const result = await RolesDBApi.update({ + id: role.id, + data: { role_customization: JSON.stringify(customization), name: role.name, permissions: role.permissions, }, - { - currentUser, - transaction, - }, - ); + currentUser, + transaction, + }); await transaction.commit(); return result; diff --git a/backend/src/services/tour_pages.js b/backend/src/services/tour_pages.js index e19f195..3bbaa60 100644 --- a/backend/src/services/tour_pages.js +++ b/backend/src/services/tour_pages.js @@ -9,6 +9,10 @@ const Tour_pagesDBApi = require('../db/api/tour_pages'); const AssetsDBApi = require('../db/api/assets'); const Asset_variantsDBApi = require('../db/api/asset_variants'); const { createEntityService } = require('../factories/service.factory'); +const { + assertCreateOptions, + assertUpdateOptions, +} = require('../contracts/entity-options'); const { downloadToBuffer, downloadToTempFile, @@ -302,11 +306,12 @@ class TourPagesService extends BaseService { const updatedPages = []; for (const [index, pageId] of orderedPageIds.entries()) { - const page = await Tour_pagesDBApi.partialUpdate( - pageId, - { sort_order: index + 1 }, - { currentUser, transaction }, - ); + const page = await Tour_pagesDBApi.partialUpdate({ + id: pageId, + data: { sort_order: index + 1 }, + currentUser, + transaction, + }); updatedPages.push(page); } @@ -429,7 +434,8 @@ class TourPagesService extends BaseService { currentUser, ); - return Tour_pagesDBApi.create(processedPayload, { + return Tour_pagesDBApi.create({ + data: processedPayload, currentUser, transaction, }); @@ -439,7 +445,10 @@ class TourPagesService extends BaseService { /** * Create tour page - generate reversed videos if needed */ - static async create(data, currentUser) { + static async create(options) { + assertCreateOptions(options, 'Service'); + const { data, currentUser, transaction, runtimeContext } = options; + // Process reversed videos and get updated ui_schema_json const updatedData = await TourPagesService.processReversedVideosAndUpdateSchema( @@ -447,15 +456,26 @@ class TourPagesService extends BaseService { currentUser, ); - return super.create(updatedData, currentUser); + return super.create({ + data: updatedData, + currentUser, + transaction, + runtimeContext, + }); } /** * Update tour page - generate reversed videos if needed */ - static async update(data, id, currentUser) { + static async update(options) { + assertUpdateOptions(options, 'Service'); + const { id, data, currentUser, transaction, runtimeContext } = options; + // Fetch existing page to get projectId (not included in update request body) - const existingPage = await Tour_pagesDBApi.findBy({ id }); + const existingPage = await Tour_pagesDBApi.findBy( + { id }, + { transaction, runtimeContext }, + ); const projectId = existingPage?.projectId || data.projectId || data.project_id; @@ -466,7 +486,13 @@ class TourPagesService extends BaseService { currentUser, ); - return super.update(updatedData, id, currentUser); + return super.update({ + id, + data: updatedData, + currentUser, + transaction, + runtimeContext, + }); } /** @@ -828,11 +854,11 @@ class TourPagesService extends BaseService { if (!pageModified) continue; - await Tour_pagesDBApi.partialUpdate( - page.id, - { ui_schema_json: JSON.stringify(uiSchema) }, - { currentUser }, - ); + await Tour_pagesDBApi.partialUpdate({ + id: page.id, + data: { ui_schema_json: JSON.stringify(uiSchema) }, + currentUser, + }); pagesUpdated++; } @@ -914,16 +940,16 @@ class TourPagesService extends BaseService { }); // Create variant record - await Asset_variantsDBApi.create( - { + await Asset_variantsDBApi.create({ + data: { assetId: asset.id, variant_type: 'reversed', cdn_url: result.url, storage_key: reversedKey, size_mb: reversedBuffer.length / (1024 * 1024), }, - { currentUser }, - ); + currentUser, + }); log.info( { reversedKey, size: reversedBuffer.length }, @@ -1034,11 +1060,11 @@ class TourPagesService extends BaseService { if (pageModified) { // Use partialUpdate to only update ui_schema_json field // This avoids the regular update's getFieldMapping which sets other fields to null - await Tour_pagesDBApi.partialUpdate( - page.id, - { ui_schema_json: JSON.stringify(uiSchema) }, - { currentUser }, - ); + await Tour_pagesDBApi.partialUpdate({ + id: page.id, + data: { ui_schema_json: JSON.stringify(uiSchema) }, + currentUser, + }); pagesUpdated++; } } diff --git a/backend/src/services/users.js b/backend/src/services/users.js index 141f656..ff3b661 100644 --- a/backend/src/services/users.js +++ b/backend/src/services/users.js @@ -1,6 +1,11 @@ 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'); @@ -126,8 +131,19 @@ class UsersService extends BaseUsersService { /** * Create user with email validation and optional invitation */ - static async create(data, currentUser, sendInvitationEmails = true, host) { - const transaction = await db.sequelize.transaction(); + 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 { @@ -157,15 +173,20 @@ class UsersService extends BaseUsersService { if (existingUser?.deletedAt) { await existingUser.restore({ transaction }); - user = await UsersDBApi.update(existingUser.id, sanitizedData, { + user = await UsersDBApi.update({ + id: existingUser.id, + data: sanitizedData, currentUser, transaction, + runtimeContext, }); } else { - user = await UsersDBApi.create( - { data: sanitizedData }, - { currentUser, transaction }, - ); + user = await UsersDBApi.create({ + data: sanitizedData, + currentUser, + transaction, + runtimeContext, + }); } await this.createProductionPresentationAccessForPublicUser({ @@ -174,7 +195,7 @@ class UsersService extends BaseUsersService { currentUser, transaction, }); - await transaction.commit(); + if (ownsTransaction) await transaction.commit(); // Send invitation email after successful commit if (sendInvitationEmails) { @@ -188,16 +209,29 @@ class UsersService extends BaseUsersService { ); } } catch (error) { - await transaction.rollback(); + if (ownsTransaction) await transaction.rollback(); throw error; } } - static async update(data, id, currentUser) { - const transaction = await db.sequelize.transaction(); + 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 }); + const existingUser = await UsersDBApi.findBy( + { id }, + { transaction, runtimeContext }, + ); if (!existingUser) { throw new ValidationError('UsersNotFound'); @@ -229,9 +263,12 @@ class UsersService extends BaseUsersService { ? { ...data, custom_permissions: [] } : data; - const user = await UsersDBApi.update(id, sanitizedData, { + const user = await UsersDBApi.update({ + id, + data: sanitizedData, currentUser, transaction, + runtimeContext, }); if ( @@ -248,10 +285,10 @@ class UsersService extends BaseUsersService { }); } - await transaction.commit(); + if (ownsTransaction) await transaction.commit(); return user; } catch (error) { - await transaction.rollback(); + if (ownsTransaction) await transaction.rollback(); throw error; } } @@ -259,7 +296,10 @@ class UsersService extends BaseUsersService { /** * Remove user with self-deletion and permission checks */ - static async remove(id, currentUser) { + static async remove(options) { + assertIdOptions(options, 'Service', 'remove'); + const { id, currentUser, transaction, runtimeContext } = options; + if (currentUser.id === id) { throw new ValidationError('iam.errors.deletingHimself'); } @@ -269,7 +309,7 @@ class UsersService extends BaseUsersService { } // Delegate to parent (factory) implementation - return super.remove(id, currentUser); + return super.remove({ id, currentUser, transaction, runtimeContext }); } } diff --git a/backend/tests/update-contracts.test.js b/backend/tests/update-contracts.test.js new file mode 100644 index 0000000..2e798ef --- /dev/null +++ b/backend/tests/update-contracts.test.js @@ -0,0 +1,486 @@ +const assert = require('node:assert/strict'); +const test = require('node:test'); + +const db = require('../src/db/models'); +const GenericDBApi = require('../src/db/api/base.api'); +const { createEntityService } = require('../src/factories/service.factory'); + +test('GenericDBApi.update uses object signature and forwards update context', async () => { + const calls = {}; + const transaction = { id: 'tx-db-api' }; + const currentUser = { id: 'user-1' }; + const record = { + async update(payload, options) { + calls.update = { payload, options }; + }, + async setTags(value, options) { + calls.setTags = { value, options }; + }, + }; + + class TestDBApi extends GenericDBApi { + static get MODEL() { + return { + rawAttributes: {}, + getTableName: () => 'test_records', + async findByPk(id, options) { + calls.findByPk = { id, options }; + return record; + }, + }; + } + + static get ASSOCIATIONS() { + return [{ field: 'tags', setter: 'setTags', isArray: true }]; + } + } + + const result = await TestDBApi.update({ + id: 'record-1', + data: { name: 'Updated', tags: ['a', 'b'], skipped: undefined }, + currentUser, + transaction, + }); + + assert.equal(result, record); + assert.deepEqual(calls.findByPk, { + id: 'record-1', + options: { transaction }, + }); + assert.deepEqual(calls.update, { + payload: { + updatedById: 'user-1', + name: 'Updated', + tags: ['a', 'b'], + }, + options: { transaction }, + }); + assert.deepEqual(calls.setTags, { + value: ['a', 'b'], + options: { transaction }, + }); +}); + +test('GenericDBApi.update rejects positional signature', async () => { + class TestDBApi extends GenericDBApi { + static get MODEL() { + return { + rawAttributes: {}, + getTableName: () => 'test_records', + }; + } + } + + await assert.rejects( + () => TestDBApi.update('record-1', { name: 'Updated' }, {}), + /DBApi\.update expects an options object/, + ); +}); + +test('GenericDBApi.create uses object signature and forwards create context', async () => { + const calls = {}; + const transaction = { id: 'tx-create' }; + const currentUser = { id: 'user-1' }; + const record = { + id: 'record-1', + async setTags(value, options) { + calls.setTags = { value, options }; + }, + }; + + class TestDBApi extends GenericDBApi { + static get MODEL() { + return { + rawAttributes: {}, + getTableName: () => 'test_records', + async create(payload, options) { + calls.create = { payload, options }; + return record; + }, + }; + } + + static get ASSOCIATIONS() { + return [{ field: 'tags', setter: 'setTags', isArray: true }]; + } + } + + const result = await TestDBApi.create({ + data: { name: 'Created', tags: ['a', 'b'] }, + currentUser, + transaction, + }); + + assert.equal(result, record); + assert.deepEqual(calls.create, { + payload: { + name: 'Created', + tags: ['a', 'b'], + importHash: null, + createdById: 'user-1', + updatedById: 'user-1', + }, + options: { transaction }, + }); + assert.deepEqual(calls.setTags, { + value: ['a', 'b'], + options: { transaction }, + }); +}); + +test('GenericDBApi.create rejects positional signature', async () => { + class TestDBApi extends GenericDBApi { + static get MODEL() { + return { + rawAttributes: {}, + getTableName: () => 'test_records', + }; + } + } + + await assert.rejects( + () => TestDBApi.create({ name: 'Created' }, {}), + /DBApi\.create requires \{ data \}/, + ); +}); + +test('GenericDBApi.partialUpdate uses object signature and only updates defined fields', async () => { + const calls = {}; + const transaction = { id: 'tx-partial' }; + const currentUser = { id: 'user-1' }; + const record = { + async update(payload, options) { + calls.update = { payload, options }; + }, + }; + + class TestDBApi extends GenericDBApi { + static get MODEL() { + return { + rawAttributes: {}, + getTableName: () => 'test_records', + async findByPk(id, options) { + calls.findByPk = { id, options }; + return record; + }, + }; + } + } + + const result = await TestDBApi.partialUpdate({ + id: 'record-1', + data: { name: 'Updated', skipped: undefined }, + currentUser, + transaction, + }); + + assert.equal(result, record); + assert.deepEqual(calls.findByPk, { + id: 'record-1', + options: { transaction }, + }); + assert.deepEqual(calls.update, { + payload: { + updatedById: 'user-1', + name: 'Updated', + }, + options: { transaction }, + }); +}); + +test('GenericDBApi.partialUpdate rejects positional signature', async () => { + class TestDBApi extends GenericDBApi { + static get MODEL() { + return { + rawAttributes: {}, + getTableName: () => 'test_records', + }; + } + } + + await assert.rejects( + () => TestDBApi.partialUpdate('record-1', { name: 'Updated' }, {}), + /DBApi\.update expects an options object/, + ); +}); + +test('createEntityService update uses object signature and manages own transaction', async () => { + const originalTransaction = db.sequelize.transaction; + const calls = {}; + const transaction = { + async commit() { + calls.committed = true; + }, + async rollback() { + calls.rolledBack = true; + }, + }; + + db.sequelize.transaction = async () => transaction; + + const DBApi = { + async findBy(where, options) { + calls.findBy = { where, options }; + return { id: where.id }; + }, + async update(options) { + calls.update = options; + return { id: options.id, ...options.data }; + }, + }; + + try { + const Service = createEntityService(DBApi, { entityName: 'TestEntity' }); + const currentUser = { id: 'user-1' }; + const runtimeContext = { environment: 'dev' }; + + const result = await Service.update({ + id: 'record-1', + data: { name: 'Updated' }, + currentUser, + runtimeContext, + }); + + assert.deepEqual(result, { id: 'record-1', name: 'Updated' }); + assert.deepEqual(calls.findBy, { + where: { id: 'record-1' }, + options: { transaction, runtimeContext }, + }); + assert.deepEqual(calls.update, { + id: 'record-1', + data: { name: 'Updated' }, + currentUser, + transaction, + runtimeContext, + }); + assert.equal(calls.committed, true); + assert.equal(calls.rolledBack, undefined); + } finally { + db.sequelize.transaction = originalTransaction; + } +}); + +test('createEntityService update rejects positional signature', async () => { + const Service = createEntityService( + { + async findBy() { + throw new Error('should not be called'); + }, + async update() { + throw new Error('should not be called'); + }, + }, + { entityName: 'TestEntity' }, + ); + + await assert.rejects( + () => Service.update({ name: 'Updated' }, 'record-1', { id: 'user-1' }), + /Service\.update requires \{ id, data \}/, + ); +}); + +test('createEntityService create, deleteByIds, and remove use object signatures', async () => { + const originalTransaction = db.sequelize.transaction; + const calls = {}; + const transaction = { + async commit() { + calls.commits = (calls.commits || 0) + 1; + }, + async rollback() { + calls.rollbacks = (calls.rollbacks || 0) + 1; + }, + }; + db.sequelize.transaction = async () => transaction; + + const DBApi = { + async create(options) { + calls.create = options; + return { id: 'created-1', ...options.data }; + }, + async deleteByIds(options) { + calls.deleteByIds = options; + }, + async remove(options) { + calls.remove = options; + }, + }; + + try { + const Service = createEntityService(DBApi, { entityName: 'TestEntity' }); + const currentUser = { id: 'user-1' }; + const runtimeContext = { environment: 'stage' }; + + const created = await Service.create({ + data: { name: 'Created' }, + currentUser, + runtimeContext, + }); + await Service.deleteByIds({ + ids: ['record-1', 'record-2'], + currentUser, + runtimeContext, + }); + await Service.remove({ + id: 'record-1', + currentUser, + runtimeContext, + }); + + assert.deepEqual(created, { id: 'created-1', name: 'Created' }); + assert.deepEqual(calls.create, { + data: { name: 'Created' }, + currentUser, + transaction, + runtimeContext, + }); + assert.deepEqual(calls.deleteByIds, { + ids: ['record-1', 'record-2'], + currentUser, + transaction, + runtimeContext, + }); + assert.deepEqual(calls.remove, { + id: 'record-1', + currentUser, + transaction, + runtimeContext, + }); + assert.equal(calls.commits, 3); + assert.equal(calls.rollbacks, undefined); + } finally { + db.sequelize.transaction = originalTransaction; + } +}); + +test('createEntityService create, deleteByIds, and remove reject positional signatures', async () => { + const Service = createEntityService( + { + async create() { + throw new Error('should not be called'); + }, + async deleteByIds() { + throw new Error('should not be called'); + }, + async remove() { + throw new Error('should not be called'); + }, + }, + { entityName: 'TestEntity' }, + ); + + await assert.rejects( + () => Service.create({ name: 'Created' }, { id: 'user-1' }), + /Service\.create requires \{ data \}/, + ); + await assert.rejects( + () => Service.deleteByIds(['record-1'], { id: 'user-1' }), + /Service\.deleteByIds expects an options object/, + ); + await assert.rejects( + () => Service.remove('record-1', { id: 'user-1' }), + /Service\.remove expects an options object/, + ); +}); + +test('GenericDBApi deleteByIds, remove, and findAllAutocomplete use object signatures', async () => { + const calls = {}; + const transaction = { id: 'tx-db-api' }; + const currentUser = { id: 'user-1' }; + const deletedRecords = [ + { + id: 'record-1', + async update(payload, options) { + calls.deletedUpdate = { payload, options }; + }, + async destroy(options) { + calls.deletedDestroy = options; + }, + }, + ]; + const removedRecord = { + id: 'record-2', + async update(payload, options) { + calls.removedUpdate = { payload, options }; + }, + async destroy(options) { + calls.removedDestroy = options; + }, + }; + const autocompleteRecords = [{ id: 'record-3', name: 'Alpha' }]; + + class TestDBApi extends GenericDBApi { + static get MODEL() { + return { + rawAttributes: {}, + getTableName: () => 'test_records', + async findAll(options) { + calls.findAll = options; + return options.attributes ? autocompleteRecords : deletedRecords; + }, + async findByPk(id, options) { + calls.findByPk = { id, options }; + return removedRecord; + }, + }; + } + } + + const deleted = await TestDBApi.deleteByIds({ + ids: ['record-1'], + currentUser, + transaction, + }); + const removed = await TestDBApi.remove({ + id: 'record-2', + currentUser, + transaction, + }); + const autocomplete = await TestDBApi.findAllAutocomplete( + { query: 'Alpha', limit: 5, offset: 0 }, + { transaction }, + ); + + assert.equal(deleted, deletedRecords); + assert.equal(removed, removedRecord); + assert.deepEqual(calls.deletedUpdate, { + payload: { deletedBy: 'user-1' }, + options: { transaction }, + }); + assert.deepEqual(calls.deletedDestroy, { transaction }); + assert.deepEqual(calls.findByPk, { + id: 'record-2', + options: { transaction }, + }); + assert.deepEqual(calls.removedUpdate, { + payload: { deletedBy: 'user-1' }, + options: { transaction }, + }); + assert.deepEqual(calls.removedDestroy, { transaction }); + assert.deepEqual(autocomplete, [{ id: 'record-3', label: 'Alpha' }]); + assert.equal(calls.findAll.limit, 5); + assert.equal(calls.findAll.transaction, transaction); +}); + +test('GenericDBApi deleteByIds, remove, and findAllAutocomplete reject positional signatures', async () => { + class TestDBApi extends GenericDBApi { + static get MODEL() { + return { + rawAttributes: {}, + getTableName: () => 'test_records', + }; + } + } + + await assert.rejects( + () => TestDBApi.deleteByIds(['record-1'], {}), + /DBApi\.deleteByIds expects an options object/, + ); + await assert.rejects( + () => TestDBApi.remove('record-1', {}), + /DBApi\.remove expects an options object/, + ); + await assert.rejects( + () => TestDBApi.findAllAutocomplete('Alpha', 5, 0), + /DBApi\.findAllAutocomplete expects an options object/, + ); +}); diff --git a/frontend/src/components/Assets/ProjectSelector.tsx b/frontend/src/components/Assets/ProjectSelector.tsx index 76982c8..57ce693 100644 --- a/frontend/src/components/Assets/ProjectSelector.tsx +++ b/frontend/src/components/Assets/ProjectSelector.tsx @@ -46,7 +46,7 @@ export function useProjectSelector({ try { const response = await axios.get( - '/projects?limit=100&page=0&sort=desc&field=updatedAt', + '/projects?limit=100&page=1&sort=desc&field=updatedAt', ); const rows = Array.isArray(response?.data?.rows) ? response.data.rows diff --git a/frontend/src/components/DataGrid/configBuilderFactory.tsx b/frontend/src/components/DataGrid/configBuilderFactory.tsx index 840ea6e..d7cceee 100644 --- a/frontend/src/components/DataGrid/configBuilderFactory.tsx +++ b/frontend/src/components/DataGrid/configBuilderFactory.tsx @@ -61,7 +61,7 @@ async function fetchRelationOptions( } try { - const response = await axios(`/${entityRef}/autocomplete?limit=100`); + const response = await axios(`/${entityRef}/autocomplete?limit=50`); return response.data; } catch (error) { logger.error( @@ -159,15 +159,34 @@ function buildColumn( if (col.type === 'singleSelectRelation' && col.entityRef) { const singleSelectColumn = baseColumn as GridSingleSelectColDef; + const valueOptions = valueOptionsMap.get(col.entityRef) || []; + singleSelectColumn.type = 'singleSelect'; singleSelectColumn.sortable = false; singleSelectColumn.getOptionValue = (value: { id?: string }) => value?.id; singleSelectColumn.getOptionLabel = (value: { label?: string }) => value?.label; - singleSelectColumn.valueOptions = valueOptionsMap.get(col.entityRef) || []; - singleSelectColumn.valueGetter = (value: { id?: string } | string | null) => + singleSelectColumn.valueOptions = valueOptions; + singleSelectColumn.valueGetter = ( + value: { id?: string; label?: string; name?: string } | string | null, + ) => (typeof value === 'object' && value !== null ? value?.id : value) ?? value; + singleSelectColumn.valueFormatter = (value: unknown) => { + if (typeof value === 'object' && value !== null) { + const relationValue = value as { + id?: string; + label?: string; + name?: string; + }; + return ( + relationValue.label || relationValue.name || relationValue.id || '' + ); + } + + const option = valueOptions.find((item) => item.id === value); + return option?.label || String(value || ''); + }; return singleSelectColumn; } diff --git a/frontend/src/components/DataGridMultiSelect.tsx b/frontend/src/components/DataGridMultiSelect.tsx index bb82434..561eac7 100644 --- a/frontend/src/components/DataGridMultiSelect.tsx +++ b/frontend/src/components/DataGridMultiSelect.tsx @@ -13,7 +13,7 @@ const DataGridMultiSelect = (props: GridRenderEditCellParams & Props) => { const [options, setOptions] = useState([]); async function callApi(entityName: string) { - const data = await axios(`/${entityName}/autocomplete?limit=100`); + const data = await axios(`/${entityName}/autocomplete?limit=50`); return data.data; } diff --git a/frontend/src/components/SelectField.tsx b/frontend/src/components/SelectField.tsx index bf9cc41..307cc7f 100644 --- a/frontend/src/components/SelectField.tsx +++ b/frontend/src/components/SelectField.tsx @@ -12,7 +12,7 @@ export const SelectField = ({ onOptionChange, }) => { const [value, setValue] = useState(null); - const PAGE_SIZE = 100; + const PAGE_SIZE = 50; useEffect(() => { if (options?.id && field?.value?.id) { diff --git a/frontend/src/components/SelectFieldMany.tsx b/frontend/src/components/SelectFieldMany.tsx index 99a7914..4e8f5a0 100644 --- a/frontend/src/components/SelectFieldMany.tsx +++ b/frontend/src/components/SelectFieldMany.tsx @@ -23,7 +23,7 @@ export const SelectFieldMany = ({ }) => { const [value, setValue] = useState([]); const appliedOptionsSignatureRef = useRef(null); - const PAGE_SIZE = 100; + const PAGE_SIZE = 50; useEffect(() => { if (field.value?.[0] && typeof field.value[0] !== 'string') { diff --git a/frontend/src/components/TourFlowManager.tsx b/frontend/src/components/TourFlowManager.tsx index cc954e6..cf6074c 100644 --- a/frontend/src/components/TourFlowManager.tsx +++ b/frontend/src/components/TourFlowManager.tsx @@ -176,7 +176,7 @@ const TourFlowManager = () => { const canDeleteTransition = hasPermission(currentUser, 'DELETE_TRANSITIONS'); const loadProjects = useCallback(async () => { - const response = await axios.get('/projects/autocomplete?limit=100'); + const response = await axios.get('/projects/autocomplete?limit=50'); const projectOptions = Array.isArray(response?.data) ? response.data : []; setProjects(projectOptions); diff --git a/frontend/src/components/WidgetCreator/RoleSelect.tsx b/frontend/src/components/WidgetCreator/RoleSelect.tsx index 1090452..607dbd9 100644 --- a/frontend/src/components/WidgetCreator/RoleSelect.tsx +++ b/frontend/src/components/WidgetCreator/RoleSelect.tsx @@ -11,7 +11,7 @@ export const RoleSelect = ({ currentUser, }) => { const [value, setValue] = useState(null); - const PAGE_SIZE = 100; + const PAGE_SIZE = 50; React.useEffect(() => { if (currentUser.app_role.id) { diff --git a/frontend/src/hooks/queries/useElementDefaultsQuery.ts b/frontend/src/hooks/queries/useElementDefaultsQuery.ts index b3c2ec5..b0b2d65 100644 --- a/frontend/src/hooks/queries/useElementDefaultsQuery.ts +++ b/frontend/src/hooks/queries/useElementDefaultsQuery.ts @@ -32,7 +32,7 @@ export function useElementDefaultsQuery(projectId: string | undefined) { Partial>> > => { const response = await axios.get( - `project-element-defaults?projectId=${projectId}&limit=200&page=0&sort=asc&field=sort_order`, + `project-element-defaults?projectId=${projectId}&limit=200&page=1&sort=asc&field=sort_order`, ); // Process and normalize the defaults diff --git a/frontend/src/pages/assets/assets-list.tsx b/frontend/src/pages/assets/assets-list.tsx index 38e1759..410834b 100644 --- a/frontend/src/pages/assets/assets-list.tsx +++ b/frontend/src/pages/assets/assets-list.tsx @@ -107,7 +107,7 @@ const AssetsTablesPage = () => { if (!projectId) return; dispatch( fetchAssets({ - query: `?page=0&sort=desc&field=createdAt&projectId=${projectId}`, + query: `?page=1&sort=desc&field=createdAt&projectId=${projectId}`, }), ); }, diff --git a/frontend/src/pages/element-type-defaults.tsx b/frontend/src/pages/element-type-defaults.tsx index 2ab552d..b58c76d 100644 --- a/frontend/src/pages/element-type-defaults.tsx +++ b/frontend/src/pages/element-type-defaults.tsx @@ -134,7 +134,7 @@ const ElementTypeDefaultsPage = () => { setErrorMessage(''); const response = await axios.get( - '/element-type-defaults?limit=1000&page=0&sort=asc&field=sort_order', + '/element-type-defaults?limit=1000&page=1&sort=asc&field=sort_order', ); const nextRows: ElementTypeDefault[] = Array.isArray(response?.data?.rows) diff --git a/frontend/src/pages/project-element-defaults.tsx b/frontend/src/pages/project-element-defaults.tsx index b84e0c0..b4bcb97 100644 --- a/frontend/src/pages/project-element-defaults.tsx +++ b/frontend/src/pages/project-element-defaults.tsx @@ -64,7 +64,7 @@ const ProjectElementDefaultsPage = () => { setErrorMessage(''); const response = await axios.get( - `/project-element-defaults?projectId=${projectId}&limit=1000&page=0&sort=asc&field=sort_order`, + `/project-element-defaults?projectId=${projectId}&limit=1000&page=1&sort=asc&field=sort_order`, ); const nextRows: ProjectElementDefault[] = Array.isArray( diff --git a/frontend/src/pages/project-element-defaults/[id].tsx b/frontend/src/pages/project-element-defaults/[id].tsx index ecbd9fd..6da8578 100644 --- a/frontend/src/pages/project-element-defaults/[id].tsx +++ b/frontend/src/pages/project-element-defaults/[id].tsx @@ -171,7 +171,7 @@ const ProjectElementDefaultDetailsPage = () => { if (nextItem.projectId) { try { const assetsResponse = await axios.get( - `/assets?limit=500&page=0&sort=desc&field=createdAt&projectId=${nextItem.projectId}`, + `/assets?limit=500&page=1&sort=desc&field=createdAt&projectId=${nextItem.projectId}`, ); const assetRows: ConstructorAsset[] = Array.isArray( assetsResponse?.data?.rows, diff --git a/frontend/src/pages/projects/projects-edit.tsx b/frontend/src/pages/projects/projects-edit.tsx index e22cb9b..e8db9d7 100644 --- a/frontend/src/pages/projects/projects-edit.tsx +++ b/frontend/src/pages/projects/projects-edit.tsx @@ -75,7 +75,7 @@ const EditProjectsPage = () => { setIsLoadingLogoAssets(true); try { const response = await axios.get( - `/assets?limit=500&page=0&sort=desc&field=createdAt&projectId=${projectId}`, + `/assets?limit=500&page=1&sort=desc&field=createdAt&projectId=${projectId}`, ); const rows = Array.isArray(response?.data?.rows) ? response.data.rows diff --git a/frontend/src/pages/projects/projects-list.tsx b/frontend/src/pages/projects/projects-list.tsx index ccef449..79e381c 100644 --- a/frontend/src/pages/projects/projects-list.tsx +++ b/frontend/src/pages/projects/projects-list.tsx @@ -41,7 +41,7 @@ const ProjectsListPage = () => { useEffect(() => { dispatch( - fetchProjects({ query: '?limit=100&page=0&sort=desc&field=updatedAt' }), + fetchProjects({ query: '?limit=100&page=1&sort=desc&field=updatedAt' }), ); }, [dispatch]);