Реалізація масової відправки документів на підписання
Масова відправка документів на підписання—це сценарій, коли одна компанія відправляє сотні або тисячі персоналізованих документів отримувачам: трудові договори при масовому найму, акти виконаних робіт фрилансерам, згоди на обробку даних користувачам.
Архітектура системи
Масова рассилка не виконується синхронно—це фонова задача з чергою та прогрес-трекингом:
Користувач завантажує CSV/Excel зі списком отримувачів
↓
Валідація даних (email, ФІО, обов'язкові поля шаблону)
↓
Створення batch записи в БД
↓
Job queue: генерація персональних документів
├── Підстановка даних у шаблон
├── Генерація PDF
├── Створення унікальної ссилки на підписання
└── Відправка email/SMS
↓
Мониторинг: хто відкрив, хто підписав, хто ігнорував
Модель даних
CREATE TABLE signing_batches (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name VARCHAR(500),
template_id UUID REFERENCES document_templates(id),
initiated_by UUID REFERENCES users(id),
total_count INT NOT NULL,
sent_count INT DEFAULT 0,
signed_count INT DEFAULT 0,
failed_count INT DEFAULT 0,
status VARCHAR(50) DEFAULT 'pending',
-- pending → processing → completed / partially_failed
deadline_at TIMESTAMPTZ,
created_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE TABLE signing_requests (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
batch_id UUID REFERENCES signing_batches(id),
recipient_email VARCHAR(500) NOT NULL,
recipient_name VARCHAR(500),
recipient_phone VARCHAR(50),
template_data JSONB NOT NULL, -- Дані для підстановки у шаблон
document_id UUID REFERENCES documents(id),
signing_token UUID UNIQUE DEFAULT gen_random_uuid(), -- Унікальний токен для ссилки
status VARCHAR(50) DEFAULT 'pending',
-- pending, generating, sent, opened, signed, declined, expired
sent_at TIMESTAMPTZ,
opened_at TIMESTAMPTZ,
signed_at TIMESTAMPTZ,
reminder_count INT DEFAULT 0,
expires_at TIMESTAMPTZ,
error_message TEXT
);
Завантаження та валідація CSV
// Парсинг та валідація файлу отримувачів
async function processBatchUpload(file: Express.Multer.File, templateId: string) {
const records = await parseCSV(file.buffer, { headers: true });
const template = await db.documentTemplates.findByPk(templateId);
const requiredFields = extractTemplateVariables(template.content);
const errors: ValidationError[] = [];
const validRows: RecipientRow[] = [];
records.forEach((row, index) => {
const rowErrors = [];
if (!row.email || !isValidEmail(row.email)) {
rowErrors.push(`Рядок ${index + 2}: некоректний email`);
}
for (const field of requiredFields) {
if (!row[field]) {
rowErrors.push(`Рядок ${index + 2}: відсутнє поле "${field}"`);
}
}
if (rowErrors.length > 0) {
errors.push(...rowErrors);
} else {
validRows.push(row);
}
});
return { valid: validRows, errors, totalRows: records.length };
}
Обробка черги
// BullMQ worker: обробляє завдання підписання з черги
const signingWorker = new Worker('document-signing', async (job) => {
const { requestId } = job.data;
const request = await db.signingRequests.findByPk(requestId);
try {
await db.signingRequests.update(requestId, { status: 'generating' });
// 1. Генеруємо персональний документ
const pdfBytes = await documentGenerator.generate(
request.template,
request.templateData
);
// 2. Зберігаємо документ
const document = await documentStorage.store(pdfBytes, {
batchId: request.batchId,
requestId: request.id,
});
// 3. Створюємо ссилку на підписання
const signingUrl = `${process.env.APP_URL}/sign/${request.signingToken}`;
// 4. Відправляємо повідомлення
await emailService.send({
to: request.recipientEmail,
subject: 'Документ очікує вашої підписи',
template: 'signing-invitation',
data: {
recipientName: request.recipientName,
documentName: request.template.name,
signingUrl,
expiresAt: request.expiresAt,
},
});
await db.signingRequests.update(requestId, {
status: 'sent',
documentId: document.id,
sentAt: new Date(),
});
// Оновлюємо лічильник batch
await db.signingBatches.increment(request.batchId, 'sent_count');
} catch (error) {
await db.signingRequests.update(requestId, {
status: 'failed',
errorMessage: error.message,
});
await db.signingBatches.increment(request.batchId, 'failed_count');
}
}, {
concurrency: 10, // Паралельна обробка
connection: redisConnection,
});
Сторінка підписання (без авторизації)
Отримувач переходить по ссилці https://app.example.com/sign/{token}—авторизація не потрібна, доступ тільки по токену:
app.get('/sign/:token', async (req, res) => {
const request = await db.signingRequests.findOne({
signingToken: req.params.token,
status: { not: ['expired', 'signed', 'declined'] },
});
if (!request) return res.redirect('/sign/invalid');
if (request.expiresAt < new Date()) {
await db.signingRequests.update(request.id, { status: 'expired' });
return res.redirect('/sign/expired');
}
// Фіксуємо відкриття
if (!request.openedAt) {
await db.signingRequests.update(request.id, { openedAt: new Date() });
}
res.render('signing-page', { request, document: request.document });
});
Нагадування
// Cron: щодневна відправка нагадувань
async function sendSigningReminders() {
const pending = await db.signingRequests.findAll({
status: 'sent',
reminderCount: { lt: 3 },
sentAt: { lt: subDays(new Date(), 2) }, // Нагадування через 2 дня
expiresAt: { gt: new Date() },
});
for (const request of pending) {
const lastReminderAt = request.lastReminderAt || request.sentAt;
const daysSinceLastReminder = differenceInDays(new Date(), lastReminderAt);
if (daysSinceLastReminder >= 2) {
await emailService.sendReminder(request);
await db.signingRequests.update(request.id, {
reminderCount: request.reminderCount + 1,
lastReminderAt: new Date(),
});
}
}
}
Дашборд мониторинга batch
Прогрес bar: відправлено/підписано/не відкрито/прострочено. Таблиця з фільтрами по статусу. Експорт у CSV отримувачів та статусів. Кнопка «Відправити нагадування» для всіх незакритих.
Обмеження швидкості рассилки
Email-провайдери мають ліміти. Для batch з 10K документів рассилка йде зі швидкістю 100–200 листів у хвилину через чергу з throttling. При використанні Resend—rate limit 100 req/s, Postmark—100 req/s.
Терміни
Завантаження CSV, валідація, створення batch та queue-based генерація PDF + відправка—7–10 днів. Сторінка підписання по токену, нагадування, мониторинг дашборд—5–7 днів.







