Standartized service/DB API contracts

This commit is contained in:
Dmitri 2026-06-29 09:38:55 +02:00
parent 27170d90ec
commit cf3deb14f7
40 changed files with 1454 additions and 339 deletions

View File

@ -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,
};

View File

@ -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) => ({

View File

@ -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);
}
}

View File

@ -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 = {}) {

View File

@ -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 = {}) {

View File

@ -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

View File

@ -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(

View File

@ -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) => ({

View File

@ -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(),
},
);
},
};

View File

@ -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);
}),
);

View File

@ -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;
}
}

View File

@ -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);
}),
);

View File

@ -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);
}),

View File

@ -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);
}),
);

View File

@ -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 });

View File

@ -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);
}),
);

View File

@ -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,
});
}
}

View File

@ -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,
});

View File

@ -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;
}
}

View File

@ -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;
}
}

View File

@ -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;
}
}

View File

@ -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

View File

@ -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;

View File

@ -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++;
}
}

View File

@ -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 });
}
}

View File

@ -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/,
);
});

View File

@ -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

View File

@ -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;
}

View File

@ -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;
}

View File

@ -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) {

View File

@ -23,7 +23,7 @@ export const SelectFieldMany = ({
}) => {
const [value, setValue] = useState([]);
const appliedOptionsSignatureRef = useRef<string | null>(null);
const PAGE_SIZE = 100;
const PAGE_SIZE = 50;
useEffect(() => {
if (field.value?.[0] && typeof field.value[0] !== 'string') {

View File

@ -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);

View File

@ -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) {

View File

@ -32,7 +32,7 @@ export function useElementDefaultsQuery(projectId: string | undefined) {
Partial<Record<CanvasElementType, Partial<CanvasElement>>>
> => {
const response = await axios.get<ElementDefaultsResponse>(
`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

View File

@ -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}`,
}),
);
},

View File

@ -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)

View File

@ -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(

View File

@ -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,

View File

@ -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

View File

@ -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]);