Назад к статьям
Статья

История изменений задач — как мы сделали аудит-лог в таск-трекере

Architecture First: как небольшой командой с ИИ сделать альтернативу Jira — Часть 20.
В предыдущей статье у задач появились атрибуты. Когда задачу могут менять несколько человек, рано или поздно возникает вопрос: кто и когда это поменял?

Краткое содержание

Как мы сделали историю изменений задач:

  1. История хранится в двух таблицах: запись о действии (кто, когда, с какой сущностью, что сделал) и список изменённых полей со старым и новым значением.
  2. Записи пишет сам сервис, который меняет задачу, в той же транзакции. Если изменение не сохранилось, записи в истории тоже не будет, и наоборот.
  3. Интерсепторы EF Core и темпоральные таблицы не подошли: решать, что считать изменением и как его показать, — это бизнес-логика, а ей не место ни в интерсепторе, ни в базе.
  4. Event sourcing тоже не подошёл. Ему нужна шина событий, которой у нас нет, а данные при нём какое-то время могут не совпадать с историей. Нам же было важно, чтобы история всегда точно отражала то, что произошло.
  5. В истории хранятся значения в том виде, в каком их показывать, — так она остаётся читаемой, даже если атрибут потом переименуют или удалят.
  6. Таблица называется OrganizationLog, а не «история задач», потому что позже мы хотим хранить в ней изменения не только задач, но и других сущностей.

Зачем задаче история

Пока задачами пользуется один человек, вопрос «кто это поменял» не возникает. В организации над одной задачей работают несколько участников: кто-то переносит её в другой статус, кто-то меняет исполнителя или атрибут, кто-то удаляет вложение. Без истории нельзя понять, как задача пришла к текущему состоянию.

Мы хотели, чтобы история отвечала на этот вопрос честно: что в ней записано, то и произошло на самом деле.

Модель: запись о действии и изменённые поля

Одно действие пользователя — одна запись в OrganizationLog:

public class OrganizationLog
{
    public long Id { get; set; }

    public long? EntityId { get; set; }
    public LogEntityType EntityType { get; set; }

    public long OrganizationId { get; set; }
    public Organization? Organization { get; set; }

    public Guid OwnerId { get; set; }
    public User? Owner { get; set; }

    public DateTime CreatedAt { get; set; }

    public LogAction Action { get; set; }

    public List<OrganizationLogItem>? Items { get; set; }
}

public enum LogEntityType
{
    Issue,
    Comment,
}

public enum LogAction
{
    Create,
    Update,
    Delete,
}

Если за одно действие поменялось несколько полей — текст, исполнитель и два атрибута, — у записи будет несколько элементов OrganizationLogItem:

public class OrganizationLogItem
{
    public long Id { get; set; }

    public long OrganizationLogId { get; set; }
    public OrganizationLog? OrganizationLog { get; set; }

    public PropertyType PropertyType { get; set; }

    [MaxLength(36)]
    public string? ParentId { get; set; }

    [MaxLength(36)]
    public string? OldValueId { get; set; }

    [MaxLength(36)]
    public string? NewValueId { get; set; }

    [MaxLength(255)]
    public string? PropertyName { get; set; }

    [MaxLength(4096)]
    public string? OldDisplayValue { get; set; }

    [MaxLength(4096)]
    public string? NewDisplayValue { get; set; }
}

PropertyType определяет, что именно поменялось: текст, статус, исполнитель, атрибут, вложение, доска или спейс. Для ссылок на другие сущности сохраняется их идентификатор (OldValueId, NewValueId), а рядом — значение в том виде, в каком его видел пользователь (OldDisplayValue, NewDisplayValue).

Одних идентификаторов недостаточно: история должна показывать то, что видел пользователь в момент изменения. Если бы в ней хранился только идентификатор варианта списка, то после переименования варианта старые записи показывали бы новое название, а после удаления — пустое место. Поэтому вместе с идентификатором сохраняется и текст значения, а в PropertyName — название атрибута на момент изменения.

Мы назвали таблицу OrganizationLog, а не IssueHistory, потому что она не привязана к задачам: тип сущности хранится в каждой записи. Сейчас туда пишутся изменения задач и комментариев, а когда понадобится история других сущностей, она будет храниться там же.

Как мы записываем историю

Чтобы история совпадала с тем, что произошло, она должна сохраняться вместе с самим изменением, в одной транзакции. Если изменение откатилось, записи в истории не должно быть.

Есть несколько распространённых способов вести историю. Вот почему они нам не подошли.

Интерсептор EF Core. Можно перехватывать SaveChanges, смотреть в ChangeTracker, какие свойства поменялись, и записывать их. Но интерсептор видит только колонки. Он не знает, что смена StatusId — это перенос задачи в другой статус, который нужно показать названием статуса, что изменение атрибута лежит в отдельной таблице значений и что у него есть название. Решать, что считать изменением и как его показать, — это бизнес-логика, и в интерсепторе ей не место.

Темпоральные таблицы. База сама хранит предыдущие версии строк. Это та же проблема, только ещё ниже: вместо истории изменений получаются копии значений, а логика того, что именно произошло, оказывается на уровне базы.

Event sourcing. Изменения записываются как события, и из них строится и текущее состояние, и история. Этот подход хорош, когда в приложении уже есть шина событий и когда допустимо, что история и данные какое-то время не совпадают. Нам было важно, чтобы история всегда совпадала с данными, поэтому она должна сохраняться в той же транзакции, что и само изменение.

Поэтому историю пишет сам сервис, который меняет данные.

Как это выглядит в коде

Работа разделена так же, как и во всём бэкенде: сервис хоста проверяет права и управляет транзакцией, а Core-сервис меняет данные (подробнее — в статье о наших инженерных принципах). Там же мы писали, что создание issue должно добавлять запись в историю независимо от того, откуда пришёл запрос — из Telegram-бота или из веб-API. Поэтому история пишется в Core-сервисе.

Сервис хоста открывает транзакцию и коммитит её, когда всё готово:

await using var transaction = await context.Database.BeginTransactionAsync(ct);

await issuesService.Update(
    issueId,
    request.AuthData.UserId,
    request.Content,
    request.AssigneeId,
    attributeUpdateRequests,
    uploadedFiles,
    request.RemoveAttachmentIds,
    ct);

await transaction.CommitAsync(ct);

Core-сервис сравнивает старые и новые значения, меняет только то, что изменилось, и для каждого изменения добавляет элемент истории:

var change = new OrganizationLog
{
    EntityId = issueId,
    EntityType = LogEntityType.Issue,
    Action = LogAction.Update,
    OwnerId = updaterId,
    CreatedAt = date,
    Items = [],
};

if (oldContent != content)
{
    settersBuilder += builder => builder.SetProperty(x => x.Content, content);
    change.Items.Add(GetContentLogItem(oldContent, content));
}

// остальные изменения (исполнитель, вложения) обрабатываются так же
change.Items.AddRange(await UpdateAttributes(issueId, issueData.OrganizationId, attributes, cancellationToken));

if (change.Items.Count != 0)
{
    context.Add(change);
    await context.SaveChangesAsync(cancellationToken);
}

Методы, которые меняют вложения и атрибуты, возвращают свои элементы истории, а запись сохраняется, только если что-то действительно поменялось. Сохранение задачи без изменений не оставляет в истории пустых записей.

Что видит пользователь

В веб-приложении в карточке задачи рядом с комментариями появилась вкладка «История». Каждая запись показывает, кто и когда что сделал, а внутри — изменённые поля со старым и новым значением. Создание и удаление задачи тоже попадают в историю, как и изменения комментариев. Интерфейс истории сделал Николай, фронтенд-разработчик, который к тому времени присоединился к проекту.

Итоги

  • История — то, что действительно произошло. Запись в истории и само изменение сохраняются в одной транзакции.
  • Историю пишет сервис, а не инфраструктура. Что считать изменением и как его показать — это бизнес-логика, поэтому ни интерсепторы EF Core, ни темпоральные таблицы не подошли.
  • Без event sourcing. Шины событий у нас нет, а расхождение истории и данных было бы неприемлемо.
  • Значения для показа сохраняются в истории. Поэтому она остаётся читаемой после переименования и удаления атрибутов.
  • Общая таблица для всех сущностей. Сейчас в неё пишутся задачи и комментарии, позже — и другие сущности.

Что дальше

Незадолго до истории к задачам в веб-приложении стало можно прикреплять картинки. Об этом — следующая статья.