Практическое руководство по нормализации данных форм чат-ботов и интеграции с бессерверной БД
Обработка отправки форм, поступающих через ботов корпоративных мессенджеров, и сохранение их в базу данных оказывается сложнее, чем кажется. Причина в том, что структуры полезной нагрузки (payload) у Slack и Discord кардинально различаются. Когда раз за разом тратишь время на интеграцию API, изучая документацию SDK, как разработчик бэкенда начинаешь задаваться вопросами о смысле жизни.
Обработка фрагментации схем мультиплатформенности
Slack при отправке модального окна передает данные в виде тройного вложенного объекта. Discord отправляет значения в виде массива компонентов. К этому добавляется давление требования ответить в течение 3 секунд после получения вебхука.
Объединим паттерн «Адаптер» и Zod для создания слоя нормализации домена.
- Определим стандартную схему бэкенда с помощью Zod.
- Реализуем
SlackPayloadAdapter и DiscordPayloadAdapter соответственно.
- Распарсим различающиеся для каждого канала пути данных и свяжем их в общую схему.
`typescript
import { z } from 'zod';
export const CommonFormSchema = z.object({
platform: z.enum(['SLACK', 'DISCORD']),
userId: z.string().min(1),
formId: z.string().min(1),
submittedAt: z.date(),
fields: z.object({
applicantName: z.string().min(2),
contactEmail: z.string().email(),
category: z.enum(['BUG', 'FEATURE', 'INQUIRY']),
description: z.string().max(2000),
}),
});
export type NormalizedFormData = z.infer;
export class SlackPayloadAdapter {
static adapt(rawPayload: any): NormalizedFormData {
const values = rawPayload.view?.state?.values || {};
return CommonFormSchema.parse({
platform: 'SLACK',
userId: rawPayload.user?.id,
formId: rawPayload.view?.callback_id,
submittedAt: new Date(),
fields: {
applicantName: values['name_block']?.['name_action']?.value,
contactEmail: values['email_block']?.['email_action']?.value,
category: values['category_block']?.['category_action']?.selected_option?.value,
description: values['desc_block']?.['desc_action']?.value,
},
});
}
}
export class DiscordPayloadAdapter {
static adapt(rawPayload: any): NormalizedFormData {
const components = rawPayload.data?.components || [];
const fieldMap: Record<string, string> = {};
for (const row of components) {
for (const comp of row.components || []) {
if (comp.custom_id) {
fieldMap[comp.custom_id] = comp.value || comp.values?.[0];
}
}
}
return CommonFormSchema.parse({
platform: 'DISCORD',
userId: rawPayload.member?.user?.id || rawPayload.user?.id,
formId: rawPayload.data?.custom_id,
submittedAt: new Date(),
fields: {
applicantName: fieldMap['applicant_name'],
contactEmail: fieldMap['contact_email'],
category: fieldMap['category'],
description: fieldMap['description'],
},
});
}
}
`
Если разделить адаптеры, то при добавлении нового мессенджера не придется менять бизнес-логику. Трудозатраты на поддержку снижаются.
Пулы соединений и транзакции в бессерверной среде
Бессерверные функции Vercel создают новый экземпляр для каждого запроса. Если подключаться к PostgreSQL напрямую традиционным способом, это приведет к превышению лимита max connections и ошибкам.
Использование бессерверного пуллера Neon позволяет значительно снизить задержку соединения и обрабатывать одновременные запросы. Для предотвращения дублирования записей необходимо использовать ключи идемпотентности.
`typescript
import { Pool } from '@neondatabase/serverless';
const pool = new Pool({ connectionString: process.env.POSTGRES_URL });
export async function insertNormalizedFormsBulk(forms: NormalizedFormData[]) {
const client = await pool.connect();
try {
await client.query('BEGIN');
const insertQuery = `
INSERT INTO form_responses (
idempotency_key,
platform,
user_id,
form_id,
payload,
created_at
)
VALUES ($1, $2, $3, $4, $5, $6)
ON CONFLICT (idempotency_key)
DO UPDATE SET
payload = EXCLUDED.payload,
created_at = EXCLUDED.created_at
RETURNING id;
`;
for (const form of forms) {
const idempotencyKey = `${form.platform}:${form.userId}:${form.formId}:${form.submittedAt.getTime()}`;
await client.query(insertQuery, [
idempotencyKey,
form.platform,
form.userId,
form.formId,
JSON.stringify(form.fields),
form.submittedAt,
]);
}
await client.query('COMMIT');
} catch (error) {
await client.query('ROLLBACK');
throw error;
} finally {
client.release();
}
}
`
Безопаснее быстро вернуть ответ 200 на вебхук, а фактическую запись в базу данных обрабатывать асинхронно. Это единственный способ избежать трехсекундного таймаута.
Исключения ввода и управление состоянием
Если просто закрыть модальное окно при неверном вводе и выдать ошибку, пользователь устанет. Обратную связь нужно давать непосредственно внутри открытого модального окна.
`typescript
export function formatSlackValidationErrorResponse(zodError: z.ZodError) {
const errorMap: Record<string, string> = {};
for (const issue of zodError.issues) {
const fieldName = issue.path[issue.path.length - 1];
if (fieldName === 'contactEmail') {
errorMap['email_block'] = issue.message;
} else if (fieldName === 'applicantName') {
errorMap['name_block'] = issue.message;
} else if (fieldName === 'description') {
errorMap['desc_block'] = issue.message;
}
}
return {
statusCode: 200,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
response_action: 'errors',
errors: errorMap,
}),
};
}
`
Управляем сессиями через Redis, устанавливая TTL на 15 минут. Если проанализировать логи ошибок, в большинстве случаев пользователи спотыкаются о неверный формат email или ограничения на длину текста. Исправление подсказок в модальных окнах и добавление обратной связи в реальном времени снижают процент ошибок при вводе.