Журнал аудита и отменяемые пакеты
Каждая инициированная пользователем мутация в Api записывается — кто, что, когда и откуда — во всех модулях, без какой-либо точечной настройки на уровне контроллера. Поверх этого журнала лежит слой пакетов: импорт или массовое действие можно пометить как пакет и позже отменить построчно, в стиле Planning Center. Оба механизма живут в единой таблице auditLogs в базе данных членства и полностью управляются из одной контрольной точки — BaseController.actionWrapper. На этой странице описано, что подлежит аудиту, где хранятся данные, какие компромиссы производительности его формируют и как отмена безопасно откатывает пакет без межбазовых транзакций.
Обзор
every mutating request (POST/PUT/PATCH/DELETE)
│
▼
BaseController.actionWrapper ──▶ derive {module, entityType, category, action}
│ from req.baseUrl + method (AUDIT_REGISTRY = overrides/opt-outs only)
│
├─ normal mode ─────────▶ run action ─▶ await AuditLogHelper.log(after-values) ──┐
│ (deletes also capture a before-image) │
│ ▼
└─ X-Batch-Id present ──▶ snapshot before-images (strict) ─▶ run action ─▶ audit rows tagged batchId
│
▼
auditLogs (membership DB, one table, all modules)
│
POST /membership/batches/:id/undo ──▶ BatchUndoHelper ──▶ walk rows reverse, per entity ┘
conflict guard → restore / delete / re-insert
Два структурных факта определяют всё, что описано ниже:
- Слой контроллера — единственное место, которое знает субъекта действия. Репозитории никогда не видят
AuthenticatedUser; только контроллеры хранятau. Контроллеры каждого модуля уже проходят черезBaseController.actionWrapper, поэтому именно здесь подключается аудит — без изменения сигнатур репозиториев где бы то ни было. - Одна таблица обслуживает все модули. Строки аудита для пожертвований, посещаемости, контента и т.д. записываются в
auditLogsбазы данных членства черезRepoManager.getRepos("membership"), даже из контроллера, не относящегося к членству. «Всё, что Джейн изменила сегодня» остаётся одним запросом.
Что подлежит аудиту
Аудит включён по умолчанию для каждого мутирующего глагола на каждом маршруте. actionWrapper выводит поля аудита из запроса без какой-либо конфигурации на уровне маршрута:
| Поле | Выводится из |
|---|---|
module | this.moduleName (владеющий модуль) |
entityType | сингуляризованный последний сегмент req.baseUrl (например, /membership/people → person) |
category | по умолчанию равна entityType |
action | ${entityType}_saved для POST /, ${entityType}_deleted для DELETE /:id, иначе ${entityType}_${method}:${routePath}, так что не-CRUD подмаршруты (например, task_post:/:id/move) фиксируются автоматически |
BaseController.AUDIT_REGISTRY предназначен только для переопределений и исключений — это не список разрешённых. Маршрут появляется в нём, чтобы переименовать категорию/тип сущности, объявить { dbModule, table } (что делает маршрут пригодным для пакетов и отмены), пометить его как sensitive (аудит анонимных мутаций) или отключить с помощью optOut: true.
Список исключений (пути записи с высокой интенсивностью, которые захлестнули бы журнал): посещаемость visits / visitsessions / sessions / checkin (воскресный шквал регистрации при прибытии) и сообщения messages / connections / devices (чат и присутствие). Всё остальное логируется.
Массовые конечные точки (people/bulk-delete, people/bulk-update, groupmembers/bulk-add, groupmembers/bulk-remove) зарегистрированы в BULK_ROUTES и создают одну строку аудита на каждый затронутый id, поэтому импорт 10 тысяч человек производит 10 тысяч строк — именно эта детализация по сущностям и делает пакет отменяемым.
Анонимные мутации (actionWrapperAnon — пожертвование гостя, регистрация гостя, отправка формы) подлежат аудиту только для маршрутов, помеченных в реестре как sensitive, и записываются с userId="anonymous" плюс IP-адресом клиента. Пожертвования возглавляют этот список; у этого пути есть реальная история регрессий.
Редактирование секретов и ограничения размера
Перед сохранением любой полезной нагрузки details AuditLogHelper.capDetails() запускает над ней sanitizeValue():
- Секретные ключи скрываются. Любое поле, чьё имя в нижнем регистре входит в
SENSITIVE_KEYS(password,token,cvv,cardnumber,routing_number,accesstoken,clientsecret, …), заменяется на"[redacted]". - Огромные скаляры удаляются. Любой URI
data:или строка размером более 4 КБ (фото в base64, blob-объекты) заменяется на"[stripped]". - Слишком большие строки обрезаются. Если сериализованный JSON превышает ~64 КБ, весь блок заменяется на
{ truncated: true }. Обрезанные строки по-прежнему можно просматривать — но их нельзя отменить (нет образа «до/после» для восстановления).
Где хранятся данные
Единая таблица auditLogs в базе данных membership обслуживает каждый модуль. Столбцы: id, churchId, userId, category, action, entityType, entityId, details (строка JSON типа MEDIUMTEXT), ipAddress, module, batchId, created. Миграция tools/migrations/membership/2026-07-04_audit_universal.ts добавляет module + batchId, расширяет details с TEXT до MEDIUMTEXT, добавляет индексы ix_auditLogs_batch (batchId) и ix_auditLogs_entity (churchId, module, entityType, entityId, created) и создаёт таблицу batches. Столбец module существует именно для того, чтобы коллизии entityType между разными модулями (note, setting встречаются в нескольких модулях) оставались фильтруемыми, а индекс по сущности — это то, что обеспечивает как историю по конкретной сущности, так и защиту от конфликтов при отмене.
Межмодульные записи проходят через RepoManager.getRepos("membership") изнутри обёртки. Порядок операций продуман намеренно: основная запись сначала фиксируется в базе данных модуля, вставка аудита — вторая. В обычном режиме ошибка вставки аудита проглатывается (console.error, её подхватывает Sentry) — аудит носит рекомендательный характер и никогда не должен срывать запрос пользователя. В режиме пакета это строгое правило (см. ниже).
- Триггеры MySQL не знают действующего пользователя (у соединения нет
au), и пришлось бы поддерживать наборы триггеров в каждой схеме. - binlog / CDC — это целый инфраструктурный проект с той же проблемой определения субъекта действия.
- Протягивание
userIdчерез каждый репозиторий означало бы затронуть сотни файлов ради передачи информации, которая у слоя контроллера уже есть. - Отдельные таблицы аудита на модуль означали бы в 7 раз больше обвязки и веерных запросов для любого межмодульного вопроса. Одна таблица в контрольной точке контроллера — это решение с минимумом кода, которое всё же захватывает субъекта действия.
Позиция по производительности
Горячий путь намеренно дёшев; цена платится только там, где это что-то покупает.
- Нет чтения-перед-записью при обычных обновлениях. Обычное сохранение не загружает старую запись. Отправленные значения после изменения сохраняются в
details.after; интерфейс восстанавливает переход «старое → новое» во время просмотра, сравнивая с предыдущей записью аудита сущности. Один запрос во время просмотра, нулевая стоимость во время записи. Поля, которых никогда не касались с момента запуска, просто не показывают значение «старое» — это приемлемо. - Удаления получают образ «до».
DELETE /:idна маршруте реестра с{ dbModule, table }сначала загружает строку обобщённым способом и сохраняет её вdetails.before. Удаления редки, и образ «до» — это вся криминалистическая ценность такой записи. - Режим пакета — единственный систематический случай чтения-перед-записью, и он опциональный — операция массового импорта уже дорога сама по себе, поэтому N снимков-чтений — это цена возможности отмены.
- Вставки аудита ожидаются.
actionWrapperсобирает промисы логирования и делаетawait Promise.allSettled(...)перед возвратом ответа. Это самый важный инвариант: на Lambda контейнер замораживается в момент возврата ответа, поэтому неожидаемая вставка молча теряется. «Выстрелил и забыл» здесь означает ошибки никогда не срывают запрос, а не не дожидайтесь — одна вставка в уже прогретый пул соединений базы данных членства занимает ~1–3 мс.
Пакеты и отмена
Пакет группирует набор мутаций, чтобы их можно было просмотреть и отменить вместе. Открыть его можно двумя способами:
- Явно:
POST /membership/batches { label, source }возвращаетbatchId. Затем клиент (B1Transfer, интерфейс импорта B1Admin) отправляетX-Batch-Id: <id>при каждом последующем сохранении/удалении.POST /membership/batches/:id/completeзакрывает пакет и проставляетitemCount. - Неявно: четыре массовые конечные точки открывают, наполняют и завершают собственный пакет в рамках одного запроса, возвращая
batchIdв ответе.
Таблица batches (база данных membership): id, churchId, userId, label, source, status (open|completed|undone|partial|failed), itemCount, created, completedAt, undoneAt.
Режим пакета строгий
Когда присутствует X-Batch-Id, actionWrapper ужесточает каждую проверку (writeBatchAuditRows):
- Пакет должен существовать, иметь статус
openи принадлежатьau.churchId— иначе 403. - Маршрут должен поддерживать пакеты (
{ dbModule, table }в реестре) — иначе 400. - Перед выполнением действия образы «до» для всех затронутых id загружаются одним запросом
WHERE id IN (...) AND churchId = ?. Если это чтение снимка не удаётся, запрос завершается с ошибкой 500, и действие не выполняется — режим пакета никогда не должен молча создавать неотменяемый журнал. (Обычный режим, напротив, работает по принципу «лучшее из возможного» и проглатывает ошибки снимка.) - После успешного выполнения действия для каждой сущности записывается одна строка аудита с
batchId,details.beforeиdetails.after, плюс явный маркер создания для строк, которые пакет создал.
Отмена
POST /membership/batches/:id/undo (разрешение: создатель пакета или Permissions.server.admin). Запрос отклоняется, если пакет не в статусе completed или старше 30-дневного окна отмены. Далее BatchUndoHelper.undo():
- Загружает строки аудита пакета и группирует их по
(module, entityType, entityId). Сущность, изменённая несколько раз внутри одного пакета, откатывается один раз, до своего истинного состояния до пакета — самого раннего образа «до», либо удаления, если пакет её создал. Именно поэтому отмена не наивно воспроизводит каждую строку: восстановление промежуточного снимка из середины пакета было бы неверным. - Для каждой сущности сначала запускает защиту от конфликтов:
auditLog.hasLaterModification()проверяет, существует ли более поздняя запись аудита для той же(module, entityType, entityId)вне этого пакета. Если да, значит сущность была изменена после импорта — она пропускается, о чём сообщается, но никогда не перезаписывается. Это использует сам журнал аудита в качестве детектора изменений; никаких столбцовmodifiedAtни в одной таблице не требуется. - Откатывает операцию согласно записанному типу, разрешая
{ dbModule, table }из реестра и используя обобщённые записи Kysely:- created → жёстко удалить строку.
- updated → записать обратно
details.before. - deleted → заново вставить
details.before(обновление-или-вставка, если строка с этим id снова всплыла).
- Каждый откат сам подлежит аудиту (
action: "<entityType>_undone", безbatchId— отмена отмены вне области действия).
Тип операции определяется явным маркером создания, а не выводится из отсутствия образа «до» — законно пустой образ «до» или обрезанная строка не должны быть ошибочно приняты за создание.
Результирующая полезная нагрузка — { restored, skippedConflicts: [...], failed: [...], status }; пакет переходит в статус undone (чисто) или partial. Межбазовой транзакции нет — отмена работает по принципу «лучшее из возможного» построчно, то же ограничение, которое Planning Center документирует для объединённых профилей.
onUndoОткат создания groupMember также должен записать groupMemberHistory («left»), иначе аналитика оттока молча ломается — устойчивый инвариант рабочего пространства. Такие сущности регистрируют обратный вызов onUndo в AUDIT_REGISTRY, который возвращает true, когда полностью обработал откат, минуя обобщённый путь. groupMembers — канонический случай (ключом служит id строки на явном пути, но personId на массовых конечных точках, и история отслеживается при каждом добавлении/удалении).
Потребительские поверхности
Обе административные поверхности находятся в разработке; замысел таков:
| Поверхность | Репозиторий | Назначение |
|---|---|---|
| Страница журнала аудита | B1Admin (ManageChurch → Audit Log) | Фильтрация по модулю/категории/пользователю/сущности и отображение различий «старое → новое» — для правок путём сравнения с предыдущей записью сущности, для удалений — из details.before. Опирается на GET /membership/auditlogs, доступ ограничен Permissions.server.admin. |
| Страница пакетов | B1Admin (тот же хаб настроек) | Список пакетов со статусом и счётчиками, Просмотр результатов (строки аудита пакета через GET /membership/batches/:id/results) и кнопка Отменить, которая показывает отчёт о пропущенных конфликтах / неудачах. |
| Пакеты импорта | B1Transfer | Открыть пакет, отправлять X-Batch-Id при обычных вызовах сохранения, завершить в конце — импорты становятся отменяемыми без новых конечных точек импорта. Устаревший importKey остаётся маркером происхождения только для создания, для отмены он заменён. |
Подводные камни, которые нельзя допустить в будущих изменениях
- Вставки аудита должны оставаться ожидаемыми. Неожидаемый вызов
AuditLogHelper.log(...)теряется при заморозке Lambda. Собирайте промисы и делайтеawait Promise.allSettledперед возвратом ответа. - Kysely отбрасывает
undefinedиз.set()/.values(). При восстановлении очищенное поле останется нетронутым.BatchUndoHelperпреобразует каждое отсутствующее поле в явныйnull(nullify) — никогда не обходите это ради «более быстрой» прямой записи. - Срок хранения должен оставаться значительно больше окна отмены.
AuditLogRepo.deleteOld()запускается ночным таймером (по умолчанию хранение 365 дней); окно отмены — 30 дней. Если срок хранения когда-нибудь приблизится к этому окну, журналы отмены будут вычищены из-под открытых пакетов. - Обрезанные строки не подлежат отмене. Полезная нагрузка
{ truncated: true }не имеет образа «до/после»; отмена сообщает о ней как оfailed, никогда не угадывает. - Порядок — сначала запись модуля, затем аудит. Никогда не переносите вставку аудита перед реальной записью и сохраняйте её строгой в режиме пакета / рекомендательной в обычном режиме.
Перечень файлов
| Область | Файлы |
|---|---|
| Обёртка / реестр | Api/src/shared/infrastructure/BaseController.ts (AUDIT_REGISTRY, BULK_ROUTES, actionWrapper, actionWrapperAnon, снимок + запись строк) |
| Движок отмены | Api/src/shared/infrastructure/BatchUndoHelper.ts |
| Помощник аудита | Api/src/modules/membership/helpers/AuditLogHelper.ts (log, capDetails/sanitizeValue, diffFields, getClientIp) |
| Контроллеры | Api/src/modules/membership/controllers/AuditLogController.ts, BatchController.ts |
| Модели / репозитории | Api/src/modules/membership/models/AuditLog.ts, Batch.ts; repositories/AuditLogRepo.ts (loadFiltered, loadForBatch, hasLaterModification, deleteOld), BatchRepo.ts |
| Миграция | Api/tools/migrations/membership/2026-07-04_audit_universal.ts |
| Административный интерфейс (в разработке) | страницы Audit Log + Batches в B1Admin; заголовок пакета импорта в B1Transfer |
Связанные страницы
- Структура модулей — как контроллер, не относящийся к членству, обращается к репозиториям членства через
RepoManager - Пожертвования — пути записи пожертвований, которые аудируются как
sensitiveдаже при анонимности - Конечные точки членства — REST-поверхность, которая несёт
X-Batch-Idи предоставляет/auditlogsи/batches