Agregar firmas externas seguras y mejorar entregabilidad de correos

This commit is contained in:
2026-04-08 22:58:24 -05:00
parent 46ec312706
commit 1e965b6e71
19 changed files with 2185 additions and 80 deletions
@@ -167,4 +167,12 @@ const loadForSigning = async (certId, userId, password) => {
return { privateKey, certificate };
};
module.exports = { upload, list, getOne, remove, loadForSigning, getLatestActiveForUser };
module.exports = {
upload,
list,
getOne,
remove,
loadForSigning,
getLatestActiveForUser,
parseCertificate,
};
@@ -0,0 +1,105 @@
const invitationsService = require('./invitations.service');
const { sendSuccess } = require('../../utils/apiResponse');
const getCtx = (req) => ({
ip: req.ip?.replace('::ffff:', ''),
userAgent: req.headers['user-agent'],
userEmail: req.user?.email || null,
});
const createFlow = async (req, res, next) => {
try {
const result = await invitationsService.createFlow(req.body, req.user.id, getCtx(req));
return sendSuccess(res, result, 201);
} catch (error) {
return next(error);
}
};
const getFlowForOwner = async (req, res, next) => {
try {
const result = await invitationsService.getFlowForOwner(Number(req.params.documentId), req.user.id);
return sendSuccess(res, result, 200);
} catch (error) {
return next(error);
}
};
const addInviteesToFlow = async (req, res, next) => {
try {
const result = await invitationsService.addInviteesToFlow(Number(req.params.documentId), req.body, req.user.id, getCtx(req));
return sendSuccess(res, result, 200);
} catch (error) {
return next(error);
}
};
const removeInvitation = async (req, res, next) => {
try {
const result = await invitationsService.removeInvitation(Number(req.params.invitationId), req.user.id, getCtx(req));
return sendSuccess(res, result, 200);
} catch (error) {
return next(error);
}
};
const getPublicInvitation = async (req, res, next) => {
try {
const result = await invitationsService.getPublicInvitation(req.params.token, getCtx(req));
return sendSuccess(res, result, 200);
} catch (error) {
return next(error);
}
};
const downloadPublicDocument = async (req, res, next) => {
try {
const { filePath, filename } = await invitationsService.getPublicDocumentFile(req.params.token, getCtx(req));
res.sendFile(filePath, {
headers: {
'Content-Type': 'application/pdf',
'Content-Disposition': `inline; filename="${filename}"`,
},
});
} catch (error) {
return next(error);
}
};
const acceptTerms = async (req, res, next) => {
try {
const result = await invitationsService.acceptTerms(req.params.token, getCtx(req));
return sendSuccess(res, result, 200);
} catch (error) {
return next(error);
}
};
const signInvitation = async (req, res, next) => {
try {
const rect = typeof req.body.rect === 'string' ? JSON.parse(req.body.rect) : req.body.rect;
const result = await invitationsService.signInvitation({
token: req.params.token,
certificateFile: req.file,
certPassword: req.body.certPassword,
page: Number(req.body.page) || 0,
rect,
reason: req.body.reason,
location: req.body.location,
}, getCtx(req));
return sendSuccess(res, result, 200);
} catch (error) {
return next(error);
}
};
module.exports = {
createFlow,
getFlowForOwner,
addInviteesToFlow,
removeInvitation,
getPublicInvitation,
downloadPublicDocument,
acceptTerms,
signInvitation,
};
@@ -0,0 +1,333 @@
const db = require('../../config/db');
const ensureSchema = async () => {
await db.execute(`
CREATE TABLE IF NOT EXISTS invitation_flows (
id INT AUTO_INCREMENT PRIMARY KEY,
document_id INT NOT NULL,
owner_user_id INT NOT NULL,
base_signature_id INT NOT NULL,
status VARCHAR(32) NOT NULL DEFAULT 'active',
current_signed_filename VARCHAR(255) NOT NULL,
current_signed_path VARCHAR(500) NOT NULL,
signature_style VARCHAR(32) NOT NULL DEFAULT 'qr',
qr_position VARCHAR(16) NULL,
qr_background VARCHAR(16) NULL,
qr_border VARCHAR(16) NULL,
signature_image_base64 MEDIUMTEXT NULL,
total_invites INT NOT NULL DEFAULT 0,
completed_invites INT NOT NULL DEFAULT 0,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
INDEX idx_invitation_flows_document_owner (document_id, owner_user_id),
INDEX idx_invitation_flows_status (status)
)
`);
await db.execute(`
CREATE TABLE IF NOT EXISTS invitation_signers (
id INT AUTO_INCREMENT PRIMARY KEY,
flow_id INT NOT NULL,
owner_user_id INT NOT NULL,
document_id INT NOT NULL,
order_index INT NOT NULL,
invitee_name VARCHAR(160) NOT NULL,
invitee_email VARCHAR(255) NOT NULL,
token VARCHAR(255) NOT NULL,
token_expires DATETIME NOT NULL,
status VARCHAR(32) NOT NULL DEFAULT 'queued',
legal_accepted_at DATETIME NULL,
viewed_at DATETIME NULL,
sent_at DATETIME NULL,
signed_at DATETIME NULL,
signed_filename VARCHAR(255) NULL,
signed_path VARCHAR(500) NULL,
certificate_subject_cn VARCHAR(255) NULL,
certificate_issuer_cn VARCHAR(255) NULL,
verification_code VARCHAR(100) NULL,
verification_url VARCHAR(500) NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
UNIQUE KEY uq_invitation_signers_token (token),
UNIQUE KEY uq_invitation_signers_flow_order (flow_id, order_index),
INDEX idx_invitation_signers_document_owner (document_id, owner_user_id),
INDEX idx_invitation_signers_status (status)
)
`);
};
const createFlow = async ({
documentId,
ownerUserId,
baseSignatureId,
currentSignedFilename,
currentSignedPath,
signatureStyle,
qrPosition,
qrBackground,
qrBorder,
signatureImageBase64,
totalInvites,
}) => {
const [result] = await db.execute(
`INSERT INTO invitation_flows
(document_id, owner_user_id, base_signature_id, current_signed_filename, current_signed_path,
signature_style, qr_position, qr_background, qr_border, signature_image_base64, total_invites)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[
documentId,
ownerUserId,
baseSignatureId,
currentSignedFilename,
currentSignedPath,
signatureStyle,
qrPosition || null,
qrBackground || null,
qrBorder || null,
signatureImageBase64 || null,
totalInvites,
]
);
return result.insertId;
};
const createInvitation = async ({
flowId,
ownerUserId,
documentId,
orderIndex,
inviteeName,
inviteeEmail,
token,
tokenExpires,
status,
}) => {
const [result] = await db.execute(
`INSERT INTO invitation_signers
(flow_id, owner_user_id, document_id, order_index, invitee_name, invitee_email, token, token_expires, status)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[flowId, ownerUserId, documentId, orderIndex, inviteeName, inviteeEmail, token, tokenExpires, status]
);
return result.insertId;
};
const findFlowByDocumentOwner = async (documentId, ownerUserId) => {
const [rows] = await db.execute(
'SELECT * FROM invitation_flows WHERE document_id = ? AND owner_user_id = ? ORDER BY id DESC LIMIT 1',
[documentId, ownerUserId]
);
return rows[0] || null;
};
const findFlowById = async (id) => {
const [rows] = await db.execute('SELECT * FROM invitation_flows WHERE id = ?', [id]);
return rows[0] || null;
};
const listInvitationsByFlow = async (flowId) => {
const [rows] = await db.execute(
'SELECT * FROM invitation_signers WHERE flow_id = ? ORDER BY order_index ASC',
[flowId]
);
return rows;
};
const getMaxOrderIndex = async (flowId) => {
const [rows] = await db.execute(
`SELECT COALESCE(MAX(order_index), 0) AS maxOrder
FROM invitation_signers
WHERE flow_id = ?`,
[flowId]
);
return Number(rows[0]?.maxOrder || 0);
};
const findInvitationByIdForOwner = async (id, ownerUserId) => {
const [rows] = await db.execute(
`SELECT i.*, f.status as flow_status, f.current_signed_filename, f.current_signed_path,
f.signature_style, f.qr_position, f.qr_background, f.qr_border, f.signature_image_base64,
f.base_signature_id, f.total_invites, f.completed_invites,
d.title as document_title, d.filename as document_filename,
u.name as owner_name, u.email as owner_email
FROM invitation_signers i
JOIN invitation_flows f ON f.id = i.flow_id
JOIN documents d ON d.id = i.document_id
JOIN users u ON u.id = i.owner_user_id
WHERE i.id = ? AND i.owner_user_id = ?
LIMIT 1`,
[id, ownerUserId]
);
return rows[0] || null;
};
const findInvitationByToken = async (token) => {
const [rows] = await db.execute(
`SELECT i.*, f.status as flow_status, f.current_signed_filename, f.current_signed_path,
f.signature_style, f.qr_position, f.qr_background, f.qr_border, f.signature_image_base64,
f.base_signature_id, f.total_invites, f.completed_invites,
d.title as document_title, d.filename as document_filename,
u.name as owner_name, u.email as owner_email
FROM invitation_signers i
JOIN invitation_flows f ON f.id = i.flow_id
JOIN documents d ON d.id = i.document_id
JOIN users u ON u.id = i.owner_user_id
WHERE i.token = ?
LIMIT 1`,
[token]
);
return rows[0] || null;
};
const markInvitationSent = async (id) => {
await db.execute(
`UPDATE invitation_signers
SET status = 'sent',
sent_at = COALESCE(sent_at, NOW())
WHERE id = ?`,
[id]
);
};
const markInvitationViewed = async (id) => {
await db.execute(
`UPDATE invitation_signers
SET status = CASE WHEN status = 'sent' THEN 'opened' ELSE status END,
viewed_at = COALESCE(viewed_at, NOW())
WHERE id = ?`,
[id]
);
};
const markInvitationTermsAccepted = async (id) => {
await db.execute(
`UPDATE invitation_signers
SET legal_accepted_at = COALESCE(legal_accepted_at, NOW())
WHERE id = ?`,
[id]
);
};
const markInvitationSigned = async ({
id,
signedFilename,
signedPath,
certificateSubjectCn,
certificateIssuerCn,
verificationCode,
verificationUrl,
}) => {
await db.execute(
`UPDATE invitation_signers
SET status = 'signed',
signed_at = NOW(),
signed_filename = ?,
signed_path = ?,
certificate_subject_cn = ?,
certificate_issuer_cn = ?,
verification_code = ?,
verification_url = ?
WHERE id = ?`,
[signedFilename, signedPath, certificateSubjectCn || null, certificateIssuerCn || null, verificationCode || null, verificationUrl || null, id]
);
};
const invalidateInvitationToken = async (id, replacementToken) => {
await db.execute(
`UPDATE invitation_signers
SET token = ?, token_expires = NOW()
WHERE id = ?`,
[replacementToken, id]
);
};
const activateInvitation = async (id) => {
await db.execute(
`UPDATE invitation_signers
SET status = 'sent',
sent_at = COALESCE(sent_at, NOW())
WHERE id = ?`,
[id]
);
};
const updateFlowProgress = async ({ flowId, currentSignedFilename, currentSignedPath, completedInvites, status }) => {
await db.execute(
`UPDATE invitation_flows
SET current_signed_filename = ?,
current_signed_path = ?,
completed_invites = ?,
status = ?
WHERE id = ?`,
[currentSignedFilename, currentSignedPath, completedInvites, status, flowId]
);
};
const findNextInvitation = async (flowId) => {
const [rows] = await db.execute(
`SELECT * FROM invitation_signers
WHERE flow_id = ? AND status = 'queued'
ORDER BY order_index ASC
LIMIT 1`,
[flowId]
);
return rows[0] || null;
};
const countSignedInvitations = async (flowId) => {
const [rows] = await db.execute(
`SELECT COUNT(*) AS total
FROM invitation_signers
WHERE flow_id = ? AND status = 'signed'`,
[flowId]
);
return Number(rows[0]?.total || 0);
};
const countInvitations = async (flowId) => {
const [rows] = await db.execute(
`SELECT COUNT(*) AS total
FROM invitation_signers
WHERE flow_id = ?`,
[flowId]
);
return Number(rows[0]?.total || 0);
};
const updateFlowCounts = async ({ flowId, totalInvites, completedInvites, status }) => {
await db.execute(
`UPDATE invitation_flows
SET total_invites = ?,
completed_invites = ?,
status = ?
WHERE id = ?`,
[totalInvites, completedInvites, status, flowId]
);
};
const removeInvitation = async (id) => {
await db.execute('DELETE FROM invitation_signers WHERE id = ?', [id]);
};
module.exports = {
ensureSchema,
createFlow,
createInvitation,
findFlowByDocumentOwner,
findFlowById,
listInvitationsByFlow,
getMaxOrderIndex,
findInvitationByIdForOwner,
findInvitationByToken,
markInvitationSent,
markInvitationViewed,
markInvitationTermsAccepted,
markInvitationSigned,
invalidateInvitationToken,
activateInvitation,
updateFlowProgress,
findNextInvitation,
countSignedInvitations,
countInvitations,
updateFlowCounts,
removeInvitation,
};
@@ -0,0 +1,20 @@
const express = require('express');
const authenticate = require('../../middlewares/authenticate');
const { uploadTempCertificate } = require('../../config/multer');
const invitationsController = require('./invitations.controller');
const { validate, createInvitationsSchema, addInviteesSchema, validateGuestMultipart } = require('./invitations.validator');
const router = express.Router();
router.get('/public/:token', invitationsController.getPublicInvitation);
router.get('/public/:token/document', invitationsController.downloadPublicDocument);
router.post('/public/:token/accept-terms', invitationsController.acceptTerms);
router.post('/public/:token/sign', uploadTempCertificate.single('certificateFile'), validateGuestMultipart, invitationsController.signInvitation);
router.use(authenticate);
router.post('/', validate(createInvitationsSchema), invitationsController.createFlow);
router.get('/document/:documentId', invitationsController.getFlowForOwner);
router.post('/document/:documentId/invitees', validate(addInviteesSchema), invitationsController.addInviteesToFlow);
router.delete('/:invitationId', invitationsController.removeInvitation);
module.exports = router;
@@ -0,0 +1,574 @@
const fs = require('fs/promises');
const path = require('path');
const crypto = require('crypto');
const { v4: uuidv4 } = require('uuid');
const invitationsRepository = require('./invitations.repository');
const documentsRepository = require('../documents/documents.repository');
const signaturesRepository = require('../signatures/signatures.repository');
const certificatesService = require('../certificates/certificates.service');
const usersRepository = require('../users/users.repository');
const { signPdf } = require('../signing/signing.service');
const auditService = require('../audit/audit.service');
const mailService = require('../mail/mail.service');
const env = require('../../config/env');
const INVITATION_EXPIRY_DAYS = 7;
const buildGuestUrl = (token) => `${env.frontendUrl.replace(/\/$/, '')}/guest-sign/${token}`;
const sanitizeBaseName = (value) => String(value || 'documento')
.replace(/\.pdf$/i, '')
.replace(/[^a-zA-Z0-9._-]/g, '_');
const createInvitationToken = () => crypto.randomBytes(24).toString('hex');
const createConsumedToken = () => `used_${crypto.randomBytes(24).toString('hex')}`;
const createExpiryDate = () => {
const date = new Date();
date.setDate(date.getDate() + INVITATION_EXPIRY_DAYS);
return date;
};
const ensureOwnerCanInvite = async (documentId, ownerUserId) => {
const document = await documentsRepository.findById(documentId);
if (!document) {
const error = new Error('Document not found.');
error.statusCode = 404;
throw error;
}
if (document.user_id !== ownerUserId) {
const error = new Error('Access denied.');
error.statusCode = 403;
throw error;
}
if (document.status !== 'signed') {
const error = new Error('El documento debe estar firmado por el usuario antes de invitar terceros.');
error.statusCode = 409;
throw error;
}
const baseSignature = await signaturesRepository.findLatestByDocumentAndUser(documentId, ownerUserId);
if (!baseSignature) {
const error = new Error('No se encontró la firma base del usuario principal.');
error.statusCode = 409;
throw error;
}
return { document, baseSignature };
};
const createFlow = async ({ documentId, invitees }, ownerUserId, context = {}) => {
if (invitees.length > 5) {
const error = new Error('Solo puedes invitar hasta 5 firmantes externos.');
error.statusCode = 400;
throw error;
}
const existingFlow = await invitationsRepository.findFlowByDocumentOwner(documentId, ownerUserId);
if (existingFlow && existingFlow.status !== 'completed') {
const error = new Error('Este documento ya tiene una cadena de firmas externas en curso.');
error.statusCode = 409;
throw error;
}
const { document, baseSignature } = await ensureOwnerCanInvite(documentId, ownerUserId);
const ownerPreferences = await usersRepository.getSignaturePreferences(ownerUserId);
const flowId = await invitationsRepository.createFlow({
documentId,
ownerUserId,
baseSignatureId: baseSignature.id,
currentSignedFilename: baseSignature.signed_filename,
currentSignedPath: baseSignature.storage_path,
signatureStyle: ownerPreferences.signatureStyle,
qrPosition: ownerPreferences.qrPosition,
qrBackground: ownerPreferences.qrBackground,
qrBorder: ownerPreferences.qrBorder,
signatureImageBase64: ownerPreferences.signatureImageBase64,
totalInvites: invitees.length,
});
const flow = await invitationsRepository.findFlowById(flowId);
const created = [];
for (let index = 0; index < invitees.length; index += 1) {
const invitee = invitees[index];
const token = createInvitationToken();
const invitationId = await invitationsRepository.createInvitation({
flowId,
ownerUserId,
documentId,
orderIndex: index + 1,
inviteeName: invitee.name.trim(),
inviteeEmail: invitee.email.trim().toLowerCase(),
token,
tokenExpires: createExpiryDate(),
status: index === 0 ? 'sent' : 'queued',
});
if (index === 0) {
await invitationsRepository.markInvitationSent(invitationId);
await mailService.sendExternalSigningInvitationEmail({
ownerName: document.owner_name || document.owner_email,
inviteeName: invitee.name.trim(),
inviteeEmail: invitee.email.trim().toLowerCase(),
documentTitle: document.title,
inviteUrl: buildGuestUrl(token),
orderIndex: 1,
totalInvites: invitees.length,
});
}
created.push({
id: invitationId,
inviteeName: invitee.name.trim(),
inviteeEmail: invitee.email.trim().toLowerCase(),
orderIndex: index + 1,
status: index === 0 ? 'sent' : 'queued',
inviteUrl: index === 0 ? buildGuestUrl(token) : null,
});
}
await auditService.logEvent({
type: 'EXTERNAL_SIGNING_FLOW_CREATED',
userId: ownerUserId,
email: context.userEmail || null,
ip: context.ip,
userAgent: context.userAgent,
metadata: {
documentId,
flowId,
totalInvites: invitees.length,
onlyP12Allowed: true,
},
});
return {
flowId,
documentId,
documentTitle: document.title,
status: flow.status,
totalInvites: invitees.length,
completedInvites: 0,
signatureStyle: ownerPreferences.signatureStyle,
invitees: created,
warnings: [
'Solo se debe invitar a personas que cuenten con firma electrónica P12/PFX.',
'Las firmas se solicitarán en el orden configurado.',
],
};
};
const getFlowForOwner = async (documentId, ownerUserId) => {
const flow = await invitationsRepository.findFlowByDocumentOwner(documentId, ownerUserId);
if (!flow) return null;
const invitees = await invitationsRepository.listInvitationsByFlow(flow.id);
return {
...flow,
invitees,
};
};
const addInviteesToFlow = async (documentId, { invitees }, ownerUserId, context = {}) => {
if (invitees.length > 5) {
const error = new Error('Solo puedes invitar hasta 5 firmantes externos.');
error.statusCode = 400;
throw error;
}
const flow = await invitationsRepository.findFlowByDocumentOwner(documentId, ownerUserId);
if (!flow) {
const error = new Error('No existe una cadena de firmas externas para este documento.');
error.statusCode = 404;
throw error;
}
if (flow.status === 'completed') {
const error = new Error('La cadena ya fue completada. Crea una nueva si necesitas más firmas.');
error.statusCode = 409;
throw error;
}
const { document } = await ensureOwnerCanInvite(documentId, ownerUserId);
const existingInvitees = await invitationsRepository.listInvitationsByFlow(flow.id);
const currentCount = existingInvitees.length;
if (currentCount + invitees.length > 5) {
const error = new Error('Solo puedes tener hasta 5 firmantes externos por documento.');
error.statusCode = 400;
throw error;
}
const existingEmails = new Set(existingInvitees.map((item) => String(item.invitee_email).toLowerCase()));
const normalizedInvitees = invitees.map((invitee) => ({
name: invitee.name.trim(),
email: invitee.email.trim().toLowerCase(),
}));
for (const invitee of normalizedInvitees) {
if (existingEmails.has(invitee.email)) {
const error = new Error(`El correo ${invitee.email} ya está en la cadena de firmas.`);
error.statusCode = 409;
throw error;
}
}
const hasActiveInvitation = existingInvitees.some((item) => ['sent', 'opened'].includes(item.status));
let shouldActivateFirstNew = !hasActiveInvitation;
let nextOrderIndex = await invitationsRepository.getMaxOrderIndex(flow.id);
for (const invitee of normalizedInvitees) {
nextOrderIndex += 1;
const token = createInvitationToken();
const status = shouldActivateFirstNew ? 'sent' : 'queued';
const invitationId = await invitationsRepository.createInvitation({
flowId: flow.id,
ownerUserId,
documentId,
orderIndex: nextOrderIndex,
inviteeName: invitee.name,
inviteeEmail: invitee.email,
token,
tokenExpires: createExpiryDate(),
status,
});
if (shouldActivateFirstNew) {
await invitationsRepository.markInvitationSent(invitationId);
await mailService.sendExternalSigningInvitationEmail({
ownerName: document.owner_name || document.owner_email,
inviteeName: invitee.name,
inviteeEmail: invitee.email,
documentTitle: document.title,
inviteUrl: buildGuestUrl(token),
orderIndex: nextOrderIndex,
totalInvites: currentCount + normalizedInvitees.length,
});
shouldActivateFirstNew = false;
}
}
await invitationsRepository.updateFlowCounts({
flowId: flow.id,
totalInvites: currentCount + normalizedInvitees.length,
completedInvites: await invitationsRepository.countSignedInvitations(flow.id),
status: 'active',
});
await auditService.logEvent({
type: 'EXTERNAL_SIGNING_INVITEES_ADDED',
userId: ownerUserId,
email: context.userEmail || null,
ip: context.ip,
userAgent: context.userAgent,
metadata: {
flowId: flow.id,
documentId,
addedCount: normalizedInvitees.length,
totalInvites: currentCount + normalizedInvitees.length,
},
});
return getFlowForOwner(documentId, ownerUserId);
};
const removeInvitation = async (invitationId, ownerUserId, context = {}) => {
const invitation = await invitationsRepository.findInvitationByIdForOwner(invitationId, ownerUserId);
if (!invitation) {
const error = new Error('Invitación no encontrada.');
error.statusCode = 404;
throw error;
}
if (invitation.status === 'signed') {
const error = new Error('No puedes eliminar una invitación que ya fue firmada.');
error.statusCode = 409;
throw error;
}
const wasActive = ['sent', 'opened'].includes(invitation.status);
await invitationsRepository.removeInvitation(invitationId);
const completedInvites = await invitationsRepository.countSignedInvitations(invitation.flow_id);
const totalInvites = await invitationsRepository.countInvitations(invitation.flow_id);
let nextInvitation = null;
if (wasActive) {
nextInvitation = await invitationsRepository.findNextInvitation(invitation.flow_id);
if (nextInvitation) {
await invitationsRepository.activateInvitation(nextInvitation.id);
await mailService.sendExternalSigningInvitationEmail({
ownerName: invitation.owner_name,
inviteeName: nextInvitation.invitee_name,
inviteeEmail: nextInvitation.invitee_email,
documentTitle: invitation.document_title,
inviteUrl: buildGuestUrl(nextInvitation.token),
orderIndex: nextInvitation.order_index,
totalInvites,
});
}
}
const flowStatus = totalInvites === 0
? 'cancelled'
: completedInvites >= totalInvites
? 'completed'
: 'active';
await invitationsRepository.updateFlowCounts({
flowId: invitation.flow_id,
totalInvites,
completedInvites,
status: flowStatus,
});
await auditService.logEvent({
type: 'EXTERNAL_SIGNING_INVITATION_REMOVED',
userId: ownerUserId,
email: context.userEmail || null,
ip: context.ip,
userAgent: context.userAgent,
metadata: {
invitationId,
flowId: invitation.flow_id,
documentId: invitation.document_id,
inviteeEmail: invitation.invitee_email,
nextInvitationId: nextInvitation?.id || null,
},
});
return getFlowForOwner(invitation.document_id, ownerUserId);
};
const getPublicInvitation = async (token, context = {}) => {
const invitation = await invitationsRepository.findInvitationByToken(token);
if (!invitation) {
const error = new Error('Invitación no encontrada o expirada.');
error.statusCode = 404;
throw error;
}
if (new Date(invitation.token_expires) < new Date()) {
const error = new Error('La invitación ha expirado.');
error.statusCode = 410;
throw error;
}
await invitationsRepository.markInvitationViewed(invitation.id);
await auditService.logEvent({
type: 'EXTERNAL_SIGNING_LINK_OPENED',
email: invitation.invitee_email,
ip: context.ip,
userAgent: context.userAgent,
metadata: {
invitationId: invitation.id,
documentId: invitation.document_id,
orderIndex: invitation.order_index,
},
});
return {
invitationId: invitation.id,
inviteeName: invitation.invitee_name,
inviteeEmail: invitation.invitee_email,
orderIndex: invitation.order_index,
totalInvites: invitation.total_invites,
status: invitation.status,
tokenExpires: invitation.token_expires,
legalAcceptedAt: invitation.legal_accepted_at,
ownerName: invitation.owner_name,
documentTitle: invitation.document_title,
documentFilename: invitation.current_signed_filename,
currentSignedFilename: invitation.current_signed_filename,
signatureStyle: invitation.signature_style,
qrPosition: invitation.qr_position,
qrBackground: invitation.qr_background,
qrBorder: invitation.qr_border,
warnings: [
'Debes usar tu propio certificado P12/PFX para firmar.',
'Los datos de tu certificado y su contraseña no serán guardados por el sistema.',
],
};
};
const acceptTerms = async (token, context = {}) => {
const invitation = await invitationsRepository.findInvitationByToken(token);
if (!invitation) {
const error = new Error('Invitación no encontrada o expirada.');
error.statusCode = 404;
throw error;
}
await invitationsRepository.markInvitationTermsAccepted(invitation.id);
await auditService.logEvent({
type: 'EXTERNAL_SIGNING_TERMS_ACCEPTED',
email: invitation.invitee_email,
ip: context.ip,
userAgent: context.userAgent,
metadata: {
invitationId: invitation.id,
documentId: invitation.document_id,
},
});
return { message: 'Condiciones aceptadas.' };
};
const getPublicDocumentFile = async (token, context = {}) => {
const invitation = await getPublicInvitation(token, context);
const rawInvitation = await invitationsRepository.findInvitationByToken(token);
return {
filePath: rawInvitation.current_signed_path,
filename: invitation.currentSignedFilename,
};
};
const signInvitation = async ({ token, certificateFile, certPassword, page, rect, reason, location }, context = {}) => {
const invitation = await invitationsRepository.findInvitationByToken(token);
if (!invitation) {
const error = new Error('Invitación no encontrada o expirada.');
error.statusCode = 404;
throw error;
}
if (new Date(invitation.token_expires) < new Date()) {
const error = new Error('La invitación ha expirado.');
error.statusCode = 410;
throw error;
}
if (!invitation.legal_accepted_at) {
const error = new Error('Debes aceptar las condiciones antes de firmar.');
error.statusCode = 409;
throw error;
}
if (invitation.status === 'signed') {
const error = new Error('Esta invitación ya fue utilizada.');
error.statusCode = 409;
throw error;
}
if (!['sent', 'opened'].includes(invitation.status)) {
const error = new Error('Esta invitación aún no está activa.');
error.statusCode = 409;
throw error;
}
const outputDir = path.join(env.storagePath, 'signed', 'invited', String(invitation.flow_id));
await fs.mkdir(outputDir, { recursive: true });
let parsedCertificate;
let signedResult;
try {
const p12Buffer = await fs.readFile(certificateFile.path);
parsedCertificate = certificatesService.parseCertificate
? certificatesService.parseCertificate(p12Buffer, certPassword)
: null;
const signerInfo = parsedCertificate || {};
const outputBaseName = sanitizeBaseName(
`${invitation.document_title}_flow_${invitation.flow_id}_invite_${invitation.order_index}`
);
signedResult = await signPdf({
pdfPath: invitation.current_signed_path,
p12Buffer,
p12Password: certPassword,
signerName: signerInfo.subjectCn || invitation.invitee_name,
reason: reason || 'Firmado por invitación segura',
location: location || '',
page,
rect,
signatureStyle: invitation.signature_style || 'qr',
qrPosition: invitation.qr_position || 'right',
qrBackground: invitation.qr_background || 'transparent',
qrBorder: invitation.qr_border || 'none',
signatureImageBase64: invitation.signature_image_base64 || null,
verificationUrl: null,
outputDir,
outputBaseName,
});
await invitationsRepository.markInvitationSigned({
id: invitation.id,
signedFilename: signedResult.signedFilename,
signedPath: signedResult.signedPath,
certificateSubjectCn: signerInfo.subjectCn || null,
certificateIssuerCn: signerInfo.issuerCn || null,
verificationCode: null,
verificationUrl: null,
});
await invitationsRepository.invalidateInvitationToken(invitation.id, createConsumedToken());
const completedInvites = await invitationsRepository.countSignedInvitations(invitation.flow_id);
const nextInvitation = await invitationsRepository.findNextInvitation(invitation.flow_id);
const flowStatus = nextInvitation ? 'active' : 'completed';
await invitationsRepository.updateFlowProgress({
flowId: invitation.flow_id,
currentSignedFilename: signedResult.signedFilename,
currentSignedPath: signedResult.signedPath,
completedInvites,
status: flowStatus,
});
await signaturesRepository.updateSignedArtifact(invitation.base_signature_id, signedResult.signedFilename, signedResult.signedPath);
if (nextInvitation) {
await invitationsRepository.activateInvitation(nextInvitation.id);
await mailService.sendExternalSigningInvitationEmail({
ownerName: invitation.owner_name,
inviteeName: nextInvitation.invitee_name,
inviteeEmail: nextInvitation.invitee_email,
documentTitle: invitation.document_title,
inviteUrl: buildGuestUrl(nextInvitation.token),
orderIndex: nextInvitation.order_index,
totalInvites: invitation.total_invites,
});
}
await mailService.sendExternalSigningOwnerNotificationEmail({
ownerName: invitation.owner_name,
ownerEmail: invitation.owner_email,
inviteeName: invitation.invitee_name,
documentTitle: invitation.document_title,
completedInvites,
totalInvites: invitation.total_invites,
nextInviteeName: nextInvitation?.invitee_name || null,
isCompleted: !nextInvitation,
});
await auditService.logEvent({
type: 'EXTERNAL_SIGNING_COMPLETED',
userId: invitation.owner_user_id,
email: invitation.invitee_email,
ip: context.ip,
userAgent: context.userAgent,
metadata: {
invitationId: invitation.id,
flowId: invitation.flow_id,
documentId: invitation.document_id,
orderIndex: invitation.order_index,
nextInvitationId: nextInvitation?.id || null,
},
});
return {
message: 'Documento firmado correctamente.',
signedFilename: signedResult.signedFilename,
orderIndex: invitation.order_index,
totalInvites: invitation.total_invites,
nextInviteeName: nextInvitation?.invitee_name || null,
completed: !nextInvitation,
};
} finally {
try { await fs.unlink(certificateFile.path); } catch {}
}
};
module.exports = {
createFlow,
getFlowForOwner,
addInviteesToFlow,
removeInvitation,
getPublicInvitation,
getPublicDocumentFile,
acceptTerms,
signInvitation,
};
@@ -0,0 +1,86 @@
const Joi = require('joi');
const createInvitationsSchema = Joi.object({
documentId: Joi.number().integer().positive().required(),
invitees: Joi.array()
.items(
Joi.object({
name: Joi.string().trim().min(2).max(160).required(),
email: Joi.string().trim().email().required(),
})
)
.min(1)
.max(5)
.required(),
});
const addInviteesSchema = Joi.object({
invitees: Joi.array()
.items(
Joi.object({
name: Joi.string().trim().min(2).max(160).required(),
email: Joi.string().trim().email().required(),
})
)
.min(1)
.max(5)
.required(),
});
const validateGuestMultipart = (req, res, next) => {
const issues = [];
const { certPassword, page, rect, acceptedTerms } = req.body;
if (!req.file) {
issues.push('Debes cargar un certificado P12 o PFX');
}
if (!certPassword) {
issues.push('La contraseña del certificado es obligatoria');
}
if (!['true', true, '1', 1].includes(acceptedTerms)) {
issues.push('Debes aceptar las condiciones para firmar');
}
if (page === undefined || Number.isNaN(Number(page))) {
issues.push('La página de firma es obligatoria');
}
if (!rect) {
issues.push('La posición de firma es obligatoria');
} else {
try {
const parsed = typeof rect === 'string' ? JSON.parse(rect) : rect;
const valid = ['x', 'y', 'width', 'height'].every((key) => Number.isFinite(Number(parsed?.[key])));
if (!valid) issues.push('La posición de firma es inválida');
} catch {
issues.push('La posición de firma es inválida');
}
}
if (issues.length > 0) {
return res.status(400).json({ success: false, data: null, error: issues.join('; ') });
}
return next();
};
const validate = (schema) => (req, res, next) => {
const { error } = schema.validate(req.body, { abortEarly: false });
if (error) {
return res.status(400).json({
success: false,
data: null,
error: error.details.map((item) => item.message).join('; '),
});
}
return next();
};
module.exports = {
validate,
createInvitationsSchema,
addInviteesSchema,
validateGuestMultipart,
};
+5 -1
View File
@@ -33,8 +33,12 @@ const getTransporter = () => {
const sendMail = async ({ to, subject, html, text, attachments }) => {
const activeTransporter = getTransporter();
const from = env.mail.from.includes('<')
? env.mail.from
: `LinkedSign <${env.mail.from}>`;
const info = await activeTransporter.sendMail({
from: env.mail.from,
from,
replyTo: env.mail.from,
to,
subject,
html,
+200 -60
View File
@@ -1,35 +1,75 @@
const env = require('../../config/env');
const mailRepository = require('./mail.repository');
const escapeHtml = (value) => String(value ?? '')
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
const buildBaseUrl = () => env.frontendUrl.replace(/\/$/, '');
const supportEmail = () => env.mail.from;
const renderEmailLayout = ({ preheader, title, intro, bodyHtml, ctaLabel, ctaUrl, footerNote }) => `
<div style="display:none;max-height:0;overflow:hidden;opacity:0;color:transparent;">
${escapeHtml(preheader)}
</div>
<div style="margin:0;padding:24px;background:#f3f6fb;font-family:Arial,sans-serif;color:#14213d;">
<div style="max-width:620px;margin:0 auto;background:#ffffff;border:1px solid #d9e2f0;border-radius:14px;overflow:hidden;">
<div style="padding:18px 24px;border-bottom:1px solid #e5ecf5;background:#f8fbff;">
<div style="font-size:18px;font-weight:700;color:#1d4ed8;">LinkedSign</div>
<div style="font-size:12px;color:#64748b;margin-top:4px;">Notificación transaccional del sistema</div>
</div>
<div style="padding:24px;">
<h2 style="margin:0 0 14px;font-size:22px;line-height:1.3;color:#0f172a;">${escapeHtml(title)}</h2>
<p style="margin:0 0 16px;font-size:14px;line-height:1.6;color:#334155;">${intro}</p>
${bodyHtml}
<div style="margin:24px 0 18px;">
<a href="${ctaUrl}" style="background:#2563eb;color:#ffffff;text-decoration:none;padding:12px 18px;border-radius:10px;display:inline-block;font-size:14px;font-weight:700;">
${escapeHtml(ctaLabel)}
</a>
</div>
<p style="margin:0 0 10px;font-size:12px;line-height:1.6;color:#64748b;">
Si el botón no funciona, copia y pega este enlace en tu navegador:
</p>
<p style="margin:0 0 18px;font-size:12px;word-break:break-all;">
<a href="${ctaUrl}" style="color:#2563eb;text-decoration:none;">${ctaUrl}</a>
</p>
${footerNote ? `<p style="margin:0;font-size:12px;line-height:1.6;color:#64748b;">${footerNote}</p>` : ''}
</div>
<div style="padding:14px 24px;border-top:1px solid #e5ecf5;background:#fafcff;font-size:11px;line-height:1.6;color:#64748b;">
Mensaje enviado por LinkedSign desde ${escapeHtml(buildBaseUrl())}. Si necesitas ayuda, responde a ${escapeHtml(supportEmail())}.
</div>
</div>
</div>
`;
const sendVerificationEmail = async ({ name, email, verificationToken, attachments = [] }) => {
const verificationUrl = `${env.frontendUrl.replace(/\/$/, '')}/auth/verify-email/${verificationToken}`;
const subject = 'Confirma tu cuenta de LinkedSign';
const verificationUrl = `${buildBaseUrl()}/auth/verify-email/${verificationToken}`;
const subject = 'Verifica tu cuenta de LinkedSign';
const text = [
`Hola ${name},`,
'',
'Confirma tu cuenta de LinkedSign usando este enlace:',
'Para activar tu cuenta de LinkedSign, verifica tu correo usando este enlace:',
verificationUrl,
'',
attachments.length > 0 ? 'Adjuntamos una copia de los documentos legales firmados durante tu registro.' : '',
'',
'Si no solicitaste esta cuenta, puedes ignorar este mensaje.',
].filter(Boolean).join('\n');
const html = `
<div style="font-family: Arial, sans-serif; line-height: 1.5; color: #111827;">
<h2 style="margin-bottom: 12px;">Confirma tu cuenta de LinkedSign</h2>
<p>Hola ${name},</p>
<p>Para activar tu cuenta, confirma tu correo electrónico haciendo clic en el siguiente botón:</p>
<p style="margin: 24px 0;">
<a href="${verificationUrl}" style="background: #4f46e5; color: #ffffff; text-decoration: none; padding: 12px 18px; border-radius: 8px; display: inline-block; font-weight: 600;">
Confirmar cuenta
</a>
</p>
<p>Si el botón no funciona, copia y pega este enlace en tu navegador:</p>
<p><a href="${verificationUrl}">${verificationUrl}</a></p>
${attachments.length > 0 ? '<p>Adjuntamos una copia de los documentos legales firmados durante tu registro inicial.</p>' : ''}
<p>Si no solicitaste esta cuenta, puedes ignorar este mensaje.</p>
</div>
`;
const html = renderEmailLayout({
preheader: 'Verifica tu correo para activar tu cuenta de LinkedSign.',
title: 'Verifica tu cuenta',
intro: `Hola ${escapeHtml(name)}. Para activar tu cuenta de LinkedSign, verifica tu correo electrónico.`,
bodyHtml: attachments.length > 0
? '<p style="margin:0 0 14px;font-size:14px;line-height:1.6;color:#334155;">Adjuntamos una copia de los documentos legales firmados durante tu registro inicial.</p>'
: '',
ctaLabel: 'Verificar cuenta',
ctaUrl: verificationUrl,
footerNote: 'Si no solicitaste esta cuenta, puedes ignorar este mensaje.',
});
const info = await mailRepository.sendMail({
to: email,
@@ -62,12 +102,19 @@ const sendPasswordChangedEmail = async ({ name, email, changedAt }) => {
'Si no realizaste este cambio, contacta soporte inmediatamente.',
].join('\n');
const html = `
<div style="font-family: Arial, sans-serif; line-height: 1.5; color: #111827;">
<h2 style="margin-bottom: 12px;">Contraseña actualizada</h2>
<p>Hola ${name},</p>
<p>Te confirmamos que la contraseña de tu cuenta de LinkedSign fue actualizada.</p>
<p><strong>Fecha:</strong> ${formattedDate}</p>
<p>Si no realizaste este cambio, contacta soporte inmediatamente.</p>
<div style="margin:0;padding:24px;background:#f3f6fb;font-family:Arial,sans-serif;color:#14213d;">
<div style="max-width:620px;margin:0 auto;background:#ffffff;border:1px solid #d9e2f0;border-radius:14px;overflow:hidden;">
<div style="padding:18px 24px;border-bottom:1px solid #e5ecf5;background:#f8fbff;">
<div style="font-size:18px;font-weight:700;color:#1d4ed8;">LinkedSign</div>
<div style="font-size:12px;color:#64748b;margin-top:4px;">Notificación transaccional del sistema</div>
</div>
<div style="padding:24px;">
<h2 style="margin:0 0 14px;font-size:22px;line-height:1.3;color:#0f172a;">Contraseña actualizada</h2>
<p style="margin:0 0 16px;font-size:14px;line-height:1.6;color:#334155;">Hola ${escapeHtml(name)}. Te confirmamos que la contraseña de tu cuenta fue actualizada.</p>
<p style="margin:0 0 18px;font-size:14px;line-height:1.6;color:#334155;"><strong>Fecha:</strong> ${escapeHtml(formattedDate)}</p>
<p style="margin:0;font-size:12px;line-height:1.6;color:#64748b;">Si no realizaste este cambio, responde a ${escapeHtml(supportEmail())}.</p>
</div>
</div>
</div>
`;
@@ -84,7 +131,7 @@ const sendPasswordChangedEmail = async ({ name, email, changedAt }) => {
};
const sendPasswordChangeConfirmationEmail = async ({ name, email, confirmationToken }) => {
const confirmationUrl = `${env.frontendUrl.replace(/\/$/, '')}/auth/confirm-password-change/${confirmationToken}`;
const confirmationUrl = `${buildBaseUrl()}/auth/confirm-password-change/${confirmationToken}`;
const subject = 'Confirma el cambio de contraseña de LinkedSign';
const text = [
`Hola ${name},`,
@@ -95,22 +142,15 @@ const sendPasswordChangeConfirmationEmail = async ({ name, email, confirmationTo
'',
'Si no realizaste esta solicitud, ignora este mensaje.',
].join('\n');
const html = `
<div style="font-family: Arial, sans-serif; line-height: 1.5; color: #111827;">
<h2 style="margin-bottom: 12px;">Confirma el cambio de contraseña</h2>
<p>Hola ${name},</p>
<p>Recibimos una solicitud para cambiar la contraseña de tu cuenta de LinkedSign.</p>
<p>Para aplicar el cambio, confirma la solicitud usando este botón:</p>
<p style="margin: 24px 0;">
<a href="${confirmationUrl}" style="background: #4f46e5; color: #ffffff; text-decoration: none; padding: 12px 18px; border-radius: 8px; display: inline-block; font-weight: 600;">
Confirmar cambio de contraseña
</a>
</p>
<p>Si el botón no funciona, copia y pega este enlace en tu navegador:</p>
<p><a href="${confirmationUrl}">${confirmationUrl}</a></p>
<p>Si no realizaste esta solicitud, ignora este mensaje.</p>
</div>
`;
const html = renderEmailLayout({
preheader: 'Confirma el cambio de contraseña de tu cuenta de LinkedSign.',
title: 'Confirma el cambio de contraseña',
intro: `Hola ${escapeHtml(name)}. Recibimos una solicitud para cambiar la contraseña de tu cuenta de LinkedSign.`,
bodyHtml: '<p style="margin:0 0 14px;font-size:14px;line-height:1.6;color:#334155;">Para aplicar el cambio, confirma esta solicitud.</p>',
ctaLabel: 'Confirmar cambio',
ctaUrl: confirmationUrl,
footerNote: 'Si no realizaste esta solicitud, puedes ignorar este mensaje.',
});
const info = await mailRepository.sendMail({
to: email,
@@ -126,7 +166,7 @@ const sendPasswordChangeConfirmationEmail = async ({ name, email, confirmationTo
};
const sendAccountLockedEmail = async ({ name, email, unlockToken }) => {
const unlockUrl = `${env.frontendUrl.replace(/\/$/, '')}/auth/unlock-account/${unlockToken}`;
const unlockUrl = `${buildBaseUrl()}/auth/unlock-account/${unlockToken}`;
const subject = 'Tu cuenta de LinkedSign fue bloqueada temporalmente';
const text = [
`Hola ${name},`,
@@ -138,23 +178,15 @@ const sendAccountLockedEmail = async ({ name, email, unlockToken }) => {
'El sistema te pedirá la contraseña de tu certificado de firma y luego solicitará una nueva contraseña de acceso.',
'Si no reconoces esta actividad, contacta soporte de inmediato.',
].join('\n');
const html = `
<div style="font-family: Arial, sans-serif; line-height: 1.5; color: #111827;">
<h2 style="margin-bottom: 12px;">Cuenta bloqueada temporalmente</h2>
<p>Hola ${name},</p>
<p>Detectamos 3 intentos fallidos de acceso y tu cuenta fue bloqueada temporalmente por seguridad.</p>
<p>Para iniciar el desbloqueo, usa este botón:</p>
<p style="margin: 24px 0;">
<a href="${unlockUrl}" style="background: #c62828; color: #ffffff; text-decoration: none; padding: 12px 18px; border-radius: 8px; display: inline-block; font-weight: 600;">
Desbloquear sistema
</a>
</p>
<p>Si el botón no funciona, copia y pega este enlace en tu navegador:</p>
<p><a href="${unlockUrl}">${unlockUrl}</a></p>
<p>El sistema te pedirá la contraseña de tu certificado de firma registrado y luego solicitará una nueva contraseña de acceso.</p>
<p>Si no reconoces esta actividad, contacta soporte de inmediato.</p>
</div>
`;
const html = renderEmailLayout({
preheader: 'Tu cuenta fue bloqueada temporalmente por seguridad.',
title: 'Cuenta bloqueada temporalmente',
intro: `Hola ${escapeHtml(name)}. Detectamos varios intentos fallidos de acceso y bloqueamos tu cuenta de forma temporal por seguridad.`,
bodyHtml: '<p style="margin:0 0 14px;font-size:14px;line-height:1.6;color:#334155;">Para iniciar el desbloqueo, el sistema te pedirá la contraseña de tu certificado registrado y luego solicitará una nueva contraseña de acceso.</p>',
ctaLabel: 'Iniciar desbloqueo',
ctaUrl: unlockUrl,
footerNote: 'Si no reconoces esta actividad, responde a este correo para revisión.',
});
const info = await mailRepository.sendMail({
to: email,
@@ -169,9 +201,117 @@ const sendAccountLockedEmail = async ({ name, email, unlockToken }) => {
};
};
const sendExternalSigningInvitationEmail = async ({
ownerName,
inviteeName,
inviteeEmail,
documentTitle,
inviteUrl,
orderIndex,
totalInvites,
}) => {
const subject = `Invitación de firma para ${documentTitle}`;
const text = [
`Hola ${inviteeName},`,
'',
`${ownerName} te ha invitado a firmar el documento "${documentTitle}".`,
`Orden de firma: ${orderIndex} de ${totalInvites}.`,
'',
'Importante:',
'- Debes usar tu propio certificado electrónico P12/PFX.',
'- Los datos de tu certificado y su contraseña no serán guardados por LinkedSign.',
'',
'Abre este enlace seguro para revisar el documento y firmarlo:',
inviteUrl,
].join('\n');
const html = renderEmailLayout({
preheader: `Tienes una invitación de firma para ${documentTitle}.`,
title: 'Invitación de firma',
intro: `Hola ${escapeHtml(inviteeName)}. ${escapeHtml(ownerName)} te ha invitado a firmar el documento ${escapeHtml(documentTitle)}.`,
bodyHtml: `
<p style="margin:0 0 14px;font-size:14px;line-height:1.6;color:#334155;"><strong>Orden de firma:</strong> ${orderIndex} de ${totalInvites}</p>
<p style="margin:0 0 8px;font-size:14px;line-height:1.6;color:#334155;">Para completar la firma:</p>
<ul style="margin:0 0 14px 18px;padding:0;color:#334155;font-size:14px;line-height:1.6;">
<li>Debes usar tu propio certificado electrónico P12/PFX.</li>
<li>Los datos del certificado y su contraseña no serán guardados por LinkedSign.</li>
</ul>
`,
ctaLabel: 'Revisar y firmar',
ctaUrl: inviteUrl,
footerNote: 'Si no esperabas esta invitación, puedes ignorar este mensaje.',
});
const info = await mailRepository.sendMail({
to: inviteeEmail,
subject,
html,
text,
});
return {
inviteUrl,
messageId: info.messageId || null,
};
};
const sendExternalSigningOwnerNotificationEmail = async ({
ownerName,
ownerEmail,
inviteeName,
documentTitle,
completedInvites,
totalInvites,
nextInviteeName,
isCompleted,
}) => {
const subject = isCompleted
? `Cadena de firmas completada: ${documentTitle}`
: `Nueva firma recibida: ${documentTitle}`;
const text = [
`Hola ${ownerName},`,
'',
`${inviteeName} firmó correctamente el documento "${documentTitle}".`,
`Avance: ${completedInvites} de ${totalInvites} firmas externas completadas.`,
isCompleted
? 'La cadena de firmas externas ha finalizado.'
: `El siguiente firmante en la lista es: ${nextInviteeName}.`,
].join('\n');
const html = `
<div style="margin:0;padding:24px;background:#f3f6fb;font-family:Arial,sans-serif;color:#14213d;">
<div style="max-width:620px;margin:0 auto;background:#ffffff;border:1px solid #d9e2f0;border-radius:14px;overflow:hidden;">
<div style="padding:18px 24px;border-bottom:1px solid #e5ecf5;background:#f8fbff;">
<div style="font-size:18px;font-weight:700;color:#1d4ed8;">LinkedSign</div>
<div style="font-size:12px;color:#64748b;margin-top:4px;">Notificación transaccional del sistema</div>
</div>
<div style="padding:24px;">
<h2 style="margin:0 0 14px;font-size:22px;line-height:1.3;color:#0f172a;">Actualización de firmas externas</h2>
<p style="margin:0 0 14px;font-size:14px;line-height:1.6;color:#334155;">Hola ${escapeHtml(ownerName)}. <strong>${escapeHtml(inviteeName)}</strong> firmó correctamente el documento <strong>${escapeHtml(documentTitle)}</strong>.</p>
<p style="margin:0 0 14px;font-size:14px;line-height:1.6;color:#334155;"><strong>Avance:</strong> ${completedInvites} de ${totalInvites} firmas externas completadas.</p>
<p style="margin:0;font-size:14px;line-height:1.6;color:#334155;">${isCompleted ? 'La cadena de firmas externas ha finalizado.' : `El siguiente firmante en la lista es: <strong>${escapeHtml(nextInviteeName)}</strong>.`}</p>
</div>
</div>
</div>
`;
const info = await mailRepository.sendMail({
to: ownerEmail,
subject,
html,
text,
});
return {
messageId: info.messageId || null,
};
};
module.exports = {
sendVerificationEmail,
sendPasswordChangedEmail,
sendPasswordChangeConfirmationEmail,
sendAccountLockedEmail,
sendExternalSigningInvitationEmail,
sendExternalSigningOwnerNotificationEmail,
};
@@ -71,6 +71,27 @@ const findByUser = async (userId) => {
return rows;
};
const findLatestByDocumentAndUser = async (documentId, userId) => {
const [rows] = await db.execute(
`SELECT *
FROM signatures
WHERE document_id = ? AND user_id = ?
ORDER BY signed_at DESC, id DESC
LIMIT 1`,
[documentId, userId]
);
return rows[0] || null;
};
const updateSignedArtifact = async (id, signedFilename, storagePath) => {
await db.execute(
`UPDATE signatures
SET signed_filename = ?, storage_path = ?
WHERE id = ?`,
[signedFilename, storagePath, id]
);
};
const saveVerificationRecord = async (code, data) => {
const current = await readVerificationMap();
current[code] = data;
@@ -107,6 +128,8 @@ module.exports = {
findByDocument,
findById,
findByUser,
findLatestByDocumentAndUser,
updateSignedArtifact,
saveVerificationRecord,
findVerificationRecord,
deleteVerificationRecordBySignatureId,
+2
View File
@@ -4,6 +4,7 @@ const auditRoutes = require('../modules/audit/audit.routes');
const certificatesRoutes = require('../modules/certificates/certificates.routes');
const downloadsRoutes = require('../modules/downloads/downloads.routes');
const documentsRoutes = require('../modules/documents/documents.routes');
const invitationsRoutes = require('../modules/invitations/invitations.routes');
const signaturesRoutes = require('../modules/signatures/signatures.routes');
const legalRoutes = require('../modules/legal/legal.routes');
const usersRoutes = require('../modules/users/users.routes');
@@ -16,6 +17,7 @@ router.use('/audit', auditRoutes);
router.use('/certificates', certificatesRoutes);
router.use('/downloads', downloadsRoutes);
router.use('/documents', documentsRoutes);
router.use('/invitations', invitationsRoutes);
router.use('/legal', legalRoutes);
router.use('/signatures', signaturesRoutes);
router.use('/users', usersRoutes);
+2
View File
@@ -1,9 +1,11 @@
const app = require('./app');
const env = require('./config/env');
const authRepository = require('./modules/auth/auth.repository');
const invitationsRepository = require('./modules/invitations/invitations.repository');
const start = async () => {
await authRepository.ensureSchema();
await invitationsRepository.ensureSchema();
app.listen(env.port, () => {
console.log(`LinkedSign backend running on port ${env.port}`);