diff --git a/.vscode/settings.json b/.vscode/settings.json
index 9ed1c8fc2..899dfc788 100644
--- a/.vscode/settings.json
+++ b/.vscode/settings.json
@@ -12,6 +12,7 @@
"editor.defaultFormatter": "dbaeumer.vscode-eslint"
},
"cSpell.words": [
- "salix"
+ "salix",
+ "fdescribe"
]
}
diff --git a/db/changes/234801/00-ACL_executeRoutine.sql b/db/changes/234801/00-ACL_executeRoutine.sql
new file mode 100644
index 000000000..cfe7018e9
--- /dev/null
+++ b/db/changes/234801/00-ACL_executeRoutine.sql
@@ -0,0 +1,4 @@
+INSERT INTO `salix`.`ACL` (model, property, accessType, permission, principalType, principalId)
+ VALUES
+ ('Application', 'executeProc', '*', 'ALLOW', 'ROLE', 'employee'),
+ ('Application', 'executeFunc', '*', 'ALLOW', 'ROLE', 'employee');
diff --git a/db/dump/structure.sql b/db/dump/structure.sql
index b242821fc..a8280dc1d 100644
--- a/db/dump/structure.sql
+++ b/db/dump/structure.sql
@@ -2352,6 +2352,90 @@ BEGIN
END IF;
END ;;
DELIMITER ;
+
+
+DELIMITER ;;
+CREATE DEFINER=`root`@`localhost` FUNCTION `account`.`user_hasRoutinePriv`(vType ENUM('PROCEDURE', 'FUNCTION'),
+ vChain VARCHAR(100),
+ vUserFk INT
+) RETURNS tinyint(1)
+ READS SQL DATA
+BEGIN
+
+/**
+ * Search if the user has privileges on routines.
+ *
+ * @param vType procedure or function
+ * @param vChain string passed with this syntax dbName.tableName
+ * @param vUserFk user to ckeck
+ * @return vHasPrivilege
+ */
+ DECLARE vHasPrivilege BOOL DEFAULT FALSE;
+ DECLARE vDb VARCHAR(50);
+ DECLARE vObject VARCHAR(50);
+ DECLARE vChainExists BOOL;
+ DECLARE vExecutePriv INT DEFAULT 262144;
+ -- 262144 = CONV(1000000000000000000, 2, 10)
+ -- 1000000000000000000 execution permission expressed in binary base
+
+ SET vDb = SUBSTRING_INDEX(vChain, '.', 1);
+ SET vChain = SUBSTRING(vChain, LENGTH(vDb) + 2);
+ SET vObject = SUBSTRING_INDEX(vChain, '.', 1);
+
+ SELECT COUNT(*) INTO vChainExists
+ FROM mysql.proc
+ WHERE db = vDb
+ AND `name` = vObject
+ AND `type` = vType
+ LIMIT 1;
+
+ IF NOT vChainExists THEN
+ RETURN FALSE;
+ END IF;
+
+ DROP TEMPORARY TABLE IF EXISTS tRole;
+ CREATE TEMPORARY TABLE tRole
+ (INDEX (`name`))
+ ENGINE = MEMORY
+ SELECT r.`name`
+ FROM user u
+ JOIN roleRole rr ON rr.role = u.role
+ JOIN `role` r ON r.id = rr.inheritsFrom
+ WHERE u.id = vUserFk;
+
+ SELECT TRUE INTO vHasPrivilege
+ FROM mysql.global_priv gp
+ JOIN tRole tr ON tr.name = gp.`User`
+ OR CONCAT('$', tr.name) = gp.`User`
+ WHERE JSON_VALUE(gp.Priv, '$.access') >= vExecutePriv
+ AND gp.Host = ''
+ LIMIT 1;
+
+ IF NOT vHasPrivilege THEN
+ SELECT TRUE INTO vHasPrivilege
+ FROM mysql.db db
+ JOIN tRole tr ON tr.name = db.`User`
+ WHERE db.Db = vDb
+ AND db.Execute_priv = 'Y';
+ END IF;
+
+ IF NOT vHasPrivilege THEN
+ SELECT TRUE INTO vHasPrivilege
+ FROM mysql.procs_priv pp
+ JOIN tRole tr ON tr.name = pp.`User`
+ WHERE pp.Db = vDb
+ AND pp.Routine_name = vObject
+ AND pp.Routine_type = vType
+ AND pp.Proc_priv = 'Execute'
+ LIMIT 1;
+ END IF;
+
+ DROP TEMPORARY TABLE tRole;
+ RETURN vHasPrivilege;
+END ;;
+DELIMITER ;
+
+
/*!50003 SET sql_mode = @saved_sql_mode */ ;
/*!50003 SET character_set_client = @saved_cs_client */ ;
/*!50003 SET character_set_results = @saved_cs_results */ ;
diff --git a/loopback/common/methods/application/execute.js b/loopback/common/methods/application/execute.js
new file mode 100644
index 000000000..a468dcd70
--- /dev/null
+++ b/loopback/common/methods/application/execute.js
@@ -0,0 +1,28 @@
+const UserError = require('vn-loopback/util/user-error');
+
+module.exports = Self => {
+ Self.execute = async(ctx, type, query, params, options) => {
+ const userId = ctx.req.accessToken.userId;
+ const models = Self.app.models;
+ params = params ?? [];
+
+ const myOptions = {userId: ctx.req.accessToken.userId};
+ if (typeof options == 'object')
+ Object.assign(myOptions, options);
+
+ const chain = query.split(' ')[1];
+
+ const [canExecute] = await models.ProcsPriv.rawSql(
+ 'SELECT account.user_hasRoutinePriv(?,?,?)',
+ [type, chain, userId],
+ myOptions);
+
+ if (!Object.values(canExecute)[0]) throw new UserError(`You don't have enough privileges`, 'ACCESS_DENIED');
+
+ const argString = params.map(() => '?').join(',');
+
+ const response = await models.ProcsPriv.rawSql(query + `(${argString})`, params, myOptions);
+ if (!Array.isArray(response)) return;
+ return response[0];
+ };
+};
diff --git a/loopback/common/methods/application/executeFunc.js b/loopback/common/methods/application/executeFunc.js
new file mode 100644
index 000000000..a42fdae67
--- /dev/null
+++ b/loopback/common/methods/application/executeFunc.js
@@ -0,0 +1,41 @@
+module.exports = Self => {
+ Self.remoteMethodCtx('executeFunc', {
+ description: 'Return result of function',
+ accessType: 'EXECUTE',
+ accepts: [
+ {
+ arg: 'routine',
+ type: 'string',
+ description: 'The routine name',
+ required: true,
+ http: {source: 'path'}
+ },
+ {
+ arg: 'schema',
+ type: 'string',
+ description: 'The routine schema',
+ required: true,
+ },
+ {
+ arg: 'params',
+ type: ['any'],
+ description: 'The params array',
+ },
+ ],
+ returns: {
+ type: 'any',
+ root: true
+ },
+ http: {
+ path: `/:routine/execute-func`,
+ verb: 'POST'
+ }
+ });
+
+ Self.executeFunc = async(ctx, routine, schema, params, options) => {
+ const query = `SELECT ${schema}.${routine}`;
+
+ const response = await Self.execute(ctx, 'FUNCTION', query, params, options);
+ return Object.values(response)[0];
+ };
+};
diff --git a/loopback/common/methods/application/executeProc.js b/loopback/common/methods/application/executeProc.js
new file mode 100644
index 000000000..a8825da0f
--- /dev/null
+++ b/loopback/common/methods/application/executeProc.js
@@ -0,0 +1,39 @@
+module.exports = Self => {
+ Self.remoteMethodCtx('executeProc', {
+ description: 'Return result of procedure',
+ accessType: 'EXECUTE',
+ accepts: [
+ {
+ arg: 'routine',
+ type: 'string',
+ description: 'The routine name',
+ required: true,
+ http: {source: 'path'}
+ },
+ {
+ arg: 'schema',
+ type: 'string',
+ description: 'The routine schema',
+ required: true,
+ },
+ {
+ arg: 'params',
+ type: ['any'],
+ description: 'The params array',
+ },
+ ],
+ returns: {
+ type: 'any',
+ root: true
+ },
+ http: {
+ path: `/:routine/execute-proc`,
+ verb: 'POST'
+ }
+ });
+
+ Self.executeProc = async(ctx, routine, schema, params, options) => {
+ const query = `CALL ${schema}.${routine}`;
+ return Self.execute(ctx, 'PROCEDURE', query, params, options);
+ };
+};
diff --git a/loopback/common/methods/application/spec/execute.spec.js b/loopback/common/methods/application/spec/execute.spec.js
new file mode 100644
index 000000000..1a0a8ace9
--- /dev/null
+++ b/loopback/common/methods/application/spec/execute.spec.js
@@ -0,0 +1,161 @@
+const models = require('vn-loopback/server/server').models;
+
+describe('Application execute()/executeProc()/executeFunc()', () => {
+ const userWithoutPrivileges = 1;
+ const userWithPrivileges = 9;
+ const userWithInheritedPrivileges = 120;
+ let tx;
+
+ function getCtx(userId) {
+ return {
+ req: {
+ accessToken: {userId},
+ headers: {origin: 'http://localhost'}
+ }
+ };
+ }
+
+ beforeEach(async() => {
+ tx = await models.Application.beginTransaction({});
+ const options = {transaction: tx};
+
+ await models.Application.rawSql(`
+ CREATE OR REPLACE PROCEDURE vn.myProcedure(vMyParam INT)
+ BEGIN
+ SELECT vMyParam myParam, t.*
+ FROM ticket t
+ LIMIT 2;
+ END
+ `, null, options);
+
+ await models.Application.rawSql(`
+ CREATE OR REPLACE FUNCTION bs.myFunction(vMyParam INT) RETURNS int(11)
+ BEGIN
+ RETURN vMyParam;
+ END
+ `, null, options);
+
+ await models.Application.rawSql(`
+ GRANT EXECUTE ON PROCEDURE vn.myProcedure TO developer;
+ GRANT EXECUTE ON FUNCTION bs.myFunction TO developer;
+ `, null, options);
+ });
+
+ it('should throw error when execute procedure and not have privileges', async() => {
+ const ctx = getCtx(userWithoutPrivileges);
+
+ let error;
+ try {
+ const options = {transaction: tx};
+
+ await models.Application.execute(
+ ctx,
+ 'PROCEDURE',
+ 'CALL vn.myProcedure',
+ [1],
+ options
+ );
+
+ await tx.rollback();
+ } catch (e) {
+ await tx.rollback();
+ error = e;
+ }
+
+ expect(error.message).toEqual(`You don't have enough privileges`);
+ });
+
+ it('should execute procedure and get data', async() => {
+ const ctx = getCtx(userWithPrivileges);
+ try {
+ const options = {transaction: tx};
+
+ const response = await models.Application.execute(
+ ctx,
+ 'PROCEDURE',
+ 'CALL vn.myProcedure',
+ [1],
+ options
+ );
+
+ expect(response.length).toEqual(2);
+ expect(response[0].myParam).toEqual(1);
+
+ await tx.rollback();
+ } catch (e) {
+ await tx.rollback();
+ throw e;
+ }
+ });
+
+ describe('Application executeProc()', () => {
+ it('should execute procedure and get data (executeProc)', async() => {
+ const ctx = getCtx(userWithPrivileges);
+ try {
+ const options = {transaction: tx};
+
+ const response = await models.Application.executeProc(
+ ctx,
+ 'myProcedure',
+ 'vn',
+ [1],
+ options
+ );
+
+ expect(response.length).toEqual(2);
+ expect(response[0].myParam).toEqual(1);
+
+ await tx.rollback();
+ } catch (e) {
+ await tx.rollback();
+ throw e;
+ }
+ });
+ });
+
+ describe('Application executeFunc()', () => {
+ it('should execute function and get data', async() => {
+ const ctx = getCtx(userWithPrivileges);
+ try {
+ const options = {transaction: tx};
+
+ const response = await models.Application.executeFunc(
+ ctx,
+ 'myFunction',
+ 'bs',
+ [1],
+ options
+ );
+
+ expect(response).toEqual(1);
+
+ await tx.rollback();
+ } catch (e) {
+ await tx.rollback();
+ throw e;
+ }
+ });
+
+ it('should execute function and get data with user with inherited privileges', async() => {
+ const ctx = getCtx(userWithInheritedPrivileges);
+ try {
+ const options = {transaction: tx};
+
+ const response = await models.Application.executeFunc(
+ ctx,
+ 'myFunction',
+ 'bs',
+ [1],
+ options
+ );
+
+ expect(response).toEqual(1);
+
+ await tx.rollback();
+ } catch (e) {
+ await tx.rollback();
+ throw e;
+ }
+ });
+ });
+});
diff --git a/loopback/common/models/application.js b/loopback/common/models/application.js
index 5e767fdc1..ac8ae78f0 100644
--- a/loopback/common/models/application.js
+++ b/loopback/common/models/application.js
@@ -2,4 +2,7 @@
module.exports = function(Self) {
require('../methods/application/status')(Self);
require('../methods/application/post')(Self);
+ require('../methods/application/execute')(Self);
+ require('../methods/application/executeProc')(Self);
+ require('../methods/application/executeFunc')(Self);
};
diff --git a/loopback/common/models/procs-priv.json b/loopback/common/models/procs-priv.json
new file mode 100644
index 000000000..25221d586
--- /dev/null
+++ b/loopback/common/models/procs-priv.json
@@ -0,0 +1,44 @@
+{
+ "name": "ProcsPriv",
+ "base": "VnModel",
+ "options": {
+ "mysql": {
+ "table": "mysql.procs_priv"
+ }
+ },
+ "properties": {
+ "name": {
+ "id": 1,
+ "type": "string",
+ "mysql": {
+ "columnName": "Routine_name"
+ }
+ },
+ "schema": {
+ "id": 3,
+ "type": "string",
+ "mysql": {
+ "columnName": "Db"
+ }
+ },
+ "role": {
+ "type": "string",
+ "mysql": {
+ "columnName": "user"
+ }
+ },
+ "type": {
+ "id": 2,
+ "type": "string",
+ "mysql": {
+ "columnName": "Routine_type"
+ }
+ },
+ "host": {
+ "type": "string",
+ "mysql": {
+ "columnName": "Host"
+ }
+ }
+ }
+}
diff --git a/loopback/server/model-config.json b/loopback/server/model-config.json
index 52b539f60..33ef3797d 100644
--- a/loopback/server/model-config.json
+++ b/loopback/server/model-config.json
@@ -49,5 +49,13 @@
},
"Container": {
"dataSource": "vn"
+ },
+ "ProcsPriv": {
+ "dataSource": "vn",
+ "options": {
+ "mysql": {
+ "table": "mysql.procs_priv"
+ }
+ }
}
-}
\ No newline at end of file
+}
diff --git a/modules/ticket/back/methods/ticket/closure.js b/modules/ticket/back/methods/ticket/closure.js
index 9f9aec9bd..7a2825a4d 100644
--- a/modules/ticket/back/methods/ticket/closure.js
+++ b/modules/ticket/back/methods/ticket/closure.js
@@ -5,177 +5,177 @@ const config = require('vn-print/core/config');
const storage = require('vn-print/core/storage');
module.exports = async function(ctx, Self, tickets, reqArgs = {}) {
- const userId = ctx.req.accessToken.userId;
- if (tickets.length == 0) return;
+ const userId = ctx.req.accessToken.userId;
+ if (tickets.length == 0) return;
- const failedtickets = [];
- for (const ticket of tickets) {
- try {
- await Self.rawSql(`CALL vn.ticket_closeByTicket(?)`, [ticket.id], {userId});
+ const failedtickets = [];
+ for (const ticket of tickets) {
+ try {
+ await Self.rawSql(`CALL vn.ticket_closeByTicket(?)`, [ticket.id], {userId});
- const [invoiceOut] = await Self.rawSql(`
- SELECT io.id, io.ref, io.serial, cny.code companyCode, io.issued
- FROM ticket t
- JOIN invoiceOut io ON io.ref = t.refFk
- JOIN company cny ON cny.id = io.companyFk
- WHERE t.id = ?
- `, [ticket.id]);
+ const [invoiceOut] = await Self.rawSql(`
+ SELECT io.id, io.ref, io.serial, cny.code companyCode, io.issued
+ FROM ticket t
+ JOIN invoiceOut io ON io.ref = t.refFk
+ JOIN company cny ON cny.id = io.companyFk
+ WHERE t.id = ?
+ `, [ticket.id]);
- const mailOptions = {
- overrideAttachments: true,
- attachments: []
- };
+ const mailOptions = {
+ overrideAttachments: true,
+ attachments: []
+ };
- const isToBeMailed = ticket.recipient && ticket.salesPersonFk && ticket.isToBeMailed;
+ const isToBeMailed = ticket.recipient && ticket.salesPersonFk && ticket.isToBeMailed;
- if (invoiceOut) {
- const args = {
- reference: invoiceOut.ref,
- recipientId: ticket.clientFk,
- recipient: ticket.recipient,
- replyTo: ticket.salesPersonEmail
- };
+ if (invoiceOut) {
+ const args = {
+ reference: invoiceOut.ref,
+ recipientId: ticket.clientFk,
+ recipient: ticket.recipient,
+ replyTo: ticket.salesPersonEmail
+ };
- const invoiceReport = new Report('invoice', args);
- const stream = await invoiceReport.toPdfStream();
+ const invoiceReport = new Report('invoice', args);
+ const stream = await invoiceReport.toPdfStream();
- const issued = invoiceOut.issued;
- const year = issued.getFullYear().toString();
- const month = (issued.getMonth() + 1).toString();
- const day = issued.getDate().toString();
+ const issued = invoiceOut.issued;
+ const year = issued.getFullYear().toString();
+ const month = (issued.getMonth() + 1).toString();
+ const day = issued.getDate().toString();
- const fileName = `${year}${invoiceOut.ref}.pdf`;
+ const fileName = `${year}${invoiceOut.ref}.pdf`;
- // Store invoice
- await storage.write(stream, {
- type: 'invoice',
- path: `${year}/${month}/${day}`,
- fileName: fileName
- });
+ // Store invoice
+ await storage.write(stream, {
+ type: 'invoice',
+ path: `${year}/${month}/${day}`,
+ fileName: fileName
+ });
- await Self.rawSql('UPDATE invoiceOut SET hasPdf = true WHERE id = ?', [invoiceOut.id], {userId});
+ await Self.rawSql('UPDATE invoiceOut SET hasPdf = true WHERE id = ?', [invoiceOut.id], {userId});
- if (isToBeMailed) {
- const invoiceAttachment = {
- filename: fileName,
- content: stream
- };
+ if (isToBeMailed) {
+ const invoiceAttachment = {
+ filename: fileName,
+ content: stream
+ };
- if (invoiceOut.serial == 'E' && invoiceOut.companyCode == 'VNL') {
- const exportation = new Report('exportation', args);
- const stream = await exportation.toPdfStream();
- const fileName = `CITES-${invoiceOut.ref}.pdf`;
+ if (invoiceOut.serial == 'E' && invoiceOut.companyCode == 'VNL') {
+ const exportation = new Report('exportation', args);
+ const stream = await exportation.toPdfStream();
+ const fileName = `CITES-${invoiceOut.ref}.pdf`;
- mailOptions.attachments.push({
- filename: fileName,
- content: stream
- });
- }
+ mailOptions.attachments.push({
+ filename: fileName,
+ content: stream
+ });
+ }
- mailOptions.attachments.push(invoiceAttachment);
+ mailOptions.attachments.push(invoiceAttachment);
- const email = new Email('invoice', args);
- await email.send(mailOptions);
- }
- } else if (isToBeMailed) {
- const args = {
- id: ticket.id,
- recipientId: ticket.clientFk,
- recipient: ticket.recipient,
- replyTo: ticket.salesPersonEmail
- };
+ const email = new Email('invoice', args);
+ await email.send(mailOptions);
+ }
+ } else if (isToBeMailed) {
+ const args = {
+ id: ticket.id,
+ recipientId: ticket.clientFk,
+ recipient: ticket.recipient,
+ replyTo: ticket.salesPersonEmail
+ };
- const email = new Email('delivery-note-link', args);
- await email.send();
- }
+ const email = new Email('delivery-note-link', args);
+ await email.send();
+ }
- // Incoterms authorization
- const [{firstOrder}] = await Self.rawSql(`
- SELECT COUNT(*) as firstOrder
- FROM ticket t
- JOIN client c ON c.id = t.clientFk
- WHERE t.clientFk = ?
- AND NOT t.isDeleted
- AND c.isVies
- `, [ticket.clientFk]);
+ // Incoterms authorization
+ const [{firstOrder}] = await Self.rawSql(`
+ SELECT COUNT(*) as firstOrder
+ FROM ticket t
+ JOIN client c ON c.id = t.clientFk
+ WHERE t.clientFk = ?
+ AND NOT t.isDeleted
+ AND c.isVies
+ `, [ticket.clientFk]);
- if (firstOrder == 1) {
- const args = {
- id: ticket.clientFk,
- companyId: ticket.companyFk,
- recipientId: ticket.clientFk,
- recipient: ticket.recipient,
- replyTo: ticket.salesPersonEmail
- };
+ if (firstOrder == 1) {
+ const args = {
+ id: ticket.clientFk,
+ companyId: ticket.companyFk,
+ recipientId: ticket.clientFk,
+ recipient: ticket.recipient,
+ replyTo: ticket.salesPersonEmail
+ };
- const email = new Email('incoterms-authorization', args);
- await email.send();
+ const email = new Email('incoterms-authorization', args);
+ await email.send();
- const [sample] = await Self.rawSql(
- `SELECT id
- FROM sample
- WHERE code = 'incoterms-authorization'
- `);
+ const [sample] = await Self.rawSql(
+ `SELECT id
+ FROM sample
+ WHERE code = 'incoterms-authorization'
+ `);
- await Self.rawSql(`
- INSERT INTO clientSample (clientFk, typeFk, companyFk) VALUES(?, ?, ?)
- `, [ticket.clientFk, sample.id, ticket.companyFk], {userId});
- };
- } catch (error) {
- // Domain not found
- if (error.responseCode == 450)
- return invalidEmail(ticket);
+ await Self.rawSql(`
+ INSERT INTO clientSample (clientFk, typeFk, companyFk) VALUES(?, ?, ?)
+ `, [ticket.clientFk, sample.id, ticket.companyFk], {userId});
+ }
+ } catch (error) {
+ // Domain not found
+ if (error.responseCode == 450)
+ return invalidEmail(ticket);
- // Save tickets on a list of failed ids
- failedtickets.push({
- id: ticket.id,
- stacktrace: error
- });
- }
- }
+ // Save tickets on a list of failed ids
+ failedtickets.push({
+ id: ticket.id,
+ stacktrace: error
+ });
+ }
+ }
- // Send email with failed tickets
- if (failedtickets.length > 0) {
- let body = 'This following tickets have failed:
';
+ // Send email with failed tickets
+ if (failedtickets.length > 0) {
+ let body = 'This following tickets have failed:
';
- for (const ticket of failedtickets) {
- body += `Ticket: ${ticket.id}
-
${ticket.stacktrace}
`;
- }
+ for (const ticket of failedtickets) {
+ body += `Ticket: ${ticket.id}
+
${ticket.stacktrace}
`;
+ }
- smtp.send({
- to: config.app.reportEmail,
- subject: '[API] Nightly ticket closure report',
- html: body
- });
- }
+ smtp.send({
+ to: config.app.reportEmail,
+ subject: '[API] Nightly ticket closure report',
+ html: body
+ });
+ }
- async function invalidEmail(ticket) {
- await Self.rawSql(`UPDATE client SET email = NULL WHERE id = ?`, [
- ticket.clientFk
- ], {userId});
+ async function invalidEmail(ticket) {
+ await Self.rawSql(`UPDATE client SET email = NULL WHERE id = ?`, [
+ ticket.clientFk
+ ], {userId});
- const oldInstance = `{"email": "${ticket.recipient}"}`;
- const newInstance = `{"email": ""}`;
- await Self.rawSql(`
- INSERT INTO clientLog (originFk, userFk, action, changedModel, oldInstance, newInstance)
- VALUES (?, NULL, 'UPDATE', 'Client', ?, ?)`, [
- ticket.clientFk,
- oldInstance,
- newInstance
- ], {userId});
+ const oldInstance = `{"email": "${ticket.recipient}"}`;
+ const newInstance = `{"email": ""}`;
+ await Self.rawSql(`
+ INSERT INTO clientLog (originFk, userFk, action, changedModel, oldInstance, newInstance)
+ VALUES (?, NULL, 'UPDATE', 'Client', ?, ?)`, [
+ ticket.clientFk,
+ oldInstance,
+ newInstance
+ ], {userId});
- const body = `No se ha podido enviar el albarán ${ticket.id}
- al cliente ${ticket.clientFk} - ${ticket.clientName}
- porque la dirección de email "${ticket.recipient}" no es correcta
- o no está disponible.
- Para evitar que se repita este error, se ha eliminado la dirección de email de la ficha del cliente.
- Actualiza la dirección de email con una correcta.`;
+ const body = `No se ha podido enviar el albarán ${ticket.id}
+ al cliente ${ticket.clientFk} - ${ticket.clientName}
+ porque la dirección de email "${ticket.recipient}" no es correcta
+ o no está disponible.
+ Para evitar que se repita este error, se ha eliminado la dirección de email de la ficha del cliente.
+ Actualiza la dirección de email con una correcta.`;
- smtp.send({
- to: ticket.salesPersonEmail,
- subject: 'No se ha podido enviar el albarán',
- html: body
- });
- }
+ smtp.send({
+ to: ticket.salesPersonEmail,
+ subject: 'No se ha podido enviar el albarán',
+ html: body
+ });
+ }
};