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