Agregar firmas externas seguras y mejorar entregabilidad de correos
This commit is contained in:
@@ -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,
|
||||
};
|
||||
Reference in New Issue
Block a user