feat(audit): per-tenant журнал мутаций OrgAuditLog (P1-18)

Domain OrgAuditLog (TenantEntity) - per-org журнал create/update/delete
для Supply/SupplierReturn/RetailSale/Demand/Product/ProductPrice/
ProductBarcode/Counterparty (белый список в IsTracked).

Реализация: OrgAuditInterceptor (SaveChangesInterceptor) снимает diff на
SavingChanges (до commit), пишет в тот же DbContext в той же транзакции -
атомарно с самой мутацией. ChangesJson формата
{ "field": { "before": X, "after": Y } } - служебные поля
(OrganizationId/CreatedAt/UpdatedAt) пропускаются.

ITenantContext получил UserId (sub claim) для атрибуции событий.
AppDbContext.SkipAudit - escape-hatch для сидеров/системных операций.

Tenant-isolation: query-filter обычный TenantEntity-фильтр. B не видит
audit-строки A; SuperAdmin без override видит всё.

Контроллер GET /api/admin/audit-log с фильтрами entityType / entityId /
userId / action / from / to. Permission OrgSettingsManage.
Web: /audit-log для Admin'а - таблица с раскрывающимся JSON diff'ом,
цветные плашки create/update/delete, фильтры по типу и действию.

Миграция Phase8b_OrgAuditLog: jsonb-колонка, индексы
(OrgId+CreatedAt), (OrgId+EntityType+EntityId), (OrgId+UserId+CreatedAt).

Тесты: 3 интеграционных (create Product создаёт audit-запись;
update Counterparty - diff содержит before/after; tenant-изоляция:
B не видит записи A).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
nns 2026-05-28 16:26:36 +05:00
parent 6b8ec5408a
commit a189a5dd6e
14 changed files with 516 additions and 1 deletions

View file

@ -0,0 +1,61 @@
using foodmarket.Application.Common;
using foodmarket.Infrastructure.Persistence;
using foodmarket.Api.Infrastructure.Authorization;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
namespace foodmarket.Api.Controllers.Admin;
/// <summary>Чтение per-tenant журнала мутаций. Запись — автоматическая через
/// <c>OrgAuditInterceptor</c>; этот контроллер только показывает. Доступ —
/// admin'у организации (через permission OrgSettingsManage). SuperAdmin
/// видит всё (через query-filter bypass + IsSuperAdmin).</summary>
[ApiController]
[Authorize]
[Route("api/admin/audit-log")]
public class OrgAuditLogController : ControllerBase
{
private readonly AppDbContext _db;
public OrgAuditLogController(AppDbContext db) => _db = db;
public record AuditRow(
Guid Id, DateTime CreatedAt,
Guid? UserId, string? UserName,
string Action, string EntityType, Guid? EntityId,
string ChangesJson);
[HttpGet, RequiresPermission("OrgSettingsManage")]
public async Task<ActionResult<PagedResult<AuditRow>>> List(
[FromQuery] PagedRequest req,
[FromQuery] string? entityType,
[FromQuery] Guid? entityId,
[FromQuery] Guid? userId,
[FromQuery] string? action,
[FromQuery] DateTime? from,
[FromQuery] DateTime? to,
CancellationToken ct)
{
var q = _db.OrgAuditLogs.AsNoTracking().AsQueryable();
if (!string.IsNullOrWhiteSpace(entityType)) q = q.Where(l => l.EntityType == entityType);
if (entityId is not null) q = q.Where(l => l.EntityId == entityId);
if (userId is not null) q = q.Where(l => l.UserId == userId);
if (!string.IsNullOrWhiteSpace(action)) q = q.Where(l => l.Action == action);
if (from is not null) q = q.Where(l => l.CreatedAt >= from);
if (to is not null) q = q.Where(l => l.CreatedAt <= to);
var total = await q.CountAsync(ct);
var items = await (from l in q.OrderByDescending(l => l.CreatedAt)
.Skip(req.Skip).Take(req.Take)
join u in _db.Users.AsNoTracking() on l.UserId equals u.Id into uj
from u in uj.DefaultIfEmpty()
select new AuditRow(
l.Id, l.CreatedAt,
l.UserId, u != null ? u.FullName : null,
l.Action, l.EntityType, l.EntityId,
l.ChangesJson))
.ToListAsync(ct);
return new PagedResult<AuditRow> { Items = items, Total = total, Page = req.Page, PageSize = req.Take };
}
}

View file

@ -69,6 +69,16 @@ public bool IsSuperAdmin
} }
} }
public Guid? UserId
{
get
{
var sub = _accessor.HttpContext?.User?.FindFirst(ClaimTypes.NameIdentifier)?.Value
?? _accessor.HttpContext?.User?.FindFirst("sub")?.Value;
return Guid.TryParse(sub, out var id) ? id : null;
}
}
public bool IsTenantOverride public bool IsTenantOverride
{ {
get get

View file

@ -46,6 +46,9 @@
// включаются ниже через UseHttpMetrics(). /metrics endpoint доступен всем // включаются ниже через UseHttpMetrics(). /metrics endpoint доступен всем
// (стандартная практика для Prometheus scrape) — на prod закрываем nginx'ом. // (стандартная практика для Prometheus scrape) — на prod закрываем nginx'ом.
builder.Services.AddSingleton<foodmarket.Api.Infrastructure.Observability.DbMetricsInterceptor>(); builder.Services.AddSingleton<foodmarket.Api.Infrastructure.Observability.DbMetricsInterceptor>();
// OrgAuditInterceptor — scoped (зависит от ITenantContext). EF тащит его
// через AddInterceptors на каждое создание DbContext (DbContext тоже scoped).
builder.Services.AddScoped<foodmarket.Infrastructure.Persistence.OrgAuditInterceptor>();
builder.Services.AddDbContext<AppDbContext>((sp, opts) => builder.Services.AddDbContext<AppDbContext>((sp, opts) =>
{ {
opts.UseNpgsql(builder.Configuration.GetConnectionString("Default"), opts.UseNpgsql(builder.Configuration.GetConnectionString("Default"),
@ -53,6 +56,8 @@
opts.UseOpenIddict(); opts.UseOpenIddict();
opts.AddInterceptors(sp.GetRequiredService< opts.AddInterceptors(sp.GetRequiredService<
foodmarket.Api.Infrastructure.Observability.DbMetricsInterceptor>()); foodmarket.Api.Infrastructure.Observability.DbMetricsInterceptor>());
opts.AddInterceptors(sp.GetRequiredService<
foodmarket.Infrastructure.Persistence.OrgAuditInterceptor>());
}); });
builder.Services.AddIdentity<User, Role>(opts => builder.Services.AddIdentity<User, Role>(opts =>

View file

@ -9,4 +9,9 @@ public interface ITenantContext
/// В этом режиме query-filter ОБЯЗАН применяться (по выбранной orgId), /// В этом режиме query-filter ОБЯЗАН применяться (по выбранной orgId),
/// иначе SuperAdmin'у возвращаются записи всех орг — нарушение изоляции.</summary> /// иначе SuperAdmin'у возвращаются записи всех орг — нарушение изоляции.</summary>
bool IsTenantOverride { get; } bool IsTenantOverride { get; }
/// <summary>Id текущего пользователя (sub claim в JWT). null для системных
/// операций без аутентификации (seed, фоновые джобы). Используется
/// для аудит-лога — кто инициировал мутацию.</summary>
Guid? UserId { get; }
} }

View file

@ -0,0 +1,24 @@
using foodmarket.Domain.Common;
namespace foodmarket.Domain.Organizations;
/// <summary>Журнал действий внутри организации: создание/правка/удаление
/// доменных сущностей (Supply, RetailSale, Demand, Product, Counterparty).
/// Tenant-scoped — каждая орга видит ТОЛЬКО свой журнал; SuperAdmin видит
/// всё (как и для прочих TenantEntity).
///
/// Пишется автоматически через <c>OrgAuditInterceptor</c> на SaveChanges:
/// для отслеживаемых типов снимается diff (old/new значения свойств),
/// сериализуется в JSON и сохраняется одной строкой.</summary>
public class OrgAuditLog : TenantEntity
{
public Guid? UserId { get; set; }
/// <summary>"create" | "update" | "delete".</summary>
public string Action { get; set; } = "";
/// <summary>Имя CLR-типа без неймспейса: "RetailSale", "Product"…</summary>
public string EntityType { get; set; } = "";
public Guid? EntityId { get; set; }
/// <summary>JSON-объект формата { "field": { "before": ..., "after": ... }, ... }.
/// Для create — поля только в "after"; для delete — только "before".</summary>
public string ChangesJson { get; set; } = "{}";
}

View file

@ -70,6 +70,12 @@ public AppDbContext(DbContextOptions<AppDbContext> options, ITenantContext tenan
public DbSet<Employee> Employees => Set<Employee>(); public DbSet<Employee> Employees => Set<Employee>();
public DbSet<EmployeeRetailPointAssignment> EmployeeRetailPointAssignments => Set<EmployeeRetailPointAssignment>(); public DbSet<EmployeeRetailPointAssignment> EmployeeRetailPointAssignments => Set<EmployeeRetailPointAssignment>();
public DbSet<SuperAdminAuditLog> SuperAdminAuditLogs => Set<SuperAdminAuditLog>(); public DbSet<SuperAdminAuditLog> SuperAdminAuditLogs => Set<SuperAdminAuditLog>();
public DbSet<OrgAuditLog> OrgAuditLogs => Set<OrgAuditLog>();
/// <summary>Если true — <see cref="OrgAuditInterceptor"/> не пишет audit-строки
/// для этого SaveChanges. Используется сидерами/миграциями, фоновыми
/// импортами (где tenant-context отсутствует) и при ручной чистке логов.</summary>
public bool SkipAudit { get; set; }
public DbSet<SystemSettings> SystemSettings => Set<SystemSettings>(); public DbSet<SystemSettings> SystemSettings => Set<SystemSettings>();
public DbSet<foodmarket.Domain.Platform.PlatformSettings> PlatformSettings => Set<foodmarket.Domain.Platform.PlatformSettings>(); public DbSet<foodmarket.Domain.Platform.PlatformSettings> PlatformSettings => Set<foodmarket.Domain.Platform.PlatformSettings>();
@ -130,6 +136,17 @@ protected override void OnModelCreating(ModelBuilder builder)
b.HasIndex(x => new { x.OrganizationId, x.CreatedAt }); b.HasIndex(x => new { x.OrganizationId, x.CreatedAt });
}); });
builder.Entity<OrgAuditLog>(b =>
{
b.ToTable("org_audit_log");
b.Property(x => x.Action).HasMaxLength(20).IsRequired();
b.Property(x => x.EntityType).HasMaxLength(100).IsRequired();
b.Property(x => x.ChangesJson).HasColumnType("jsonb").IsRequired();
b.HasIndex(x => new { x.OrganizationId, x.CreatedAt });
b.HasIndex(x => new { x.OrganizationId, x.EntityType, x.EntityId });
b.HasIndex(x => new { x.OrganizationId, x.UserId, x.CreatedAt });
});
builder.ConfigureCatalog(); builder.ConfigureCatalog();
builder.ConfigureInventory(); builder.ConfigureInventory();
builder.ConfigurePurchases(); builder.ConfigurePurchases();

View file

@ -21,5 +21,6 @@ private sealed class DesignTimeTenantContext : ITenantContext
public bool IsAuthenticated => false; public bool IsAuthenticated => false;
public bool IsSuperAdmin => true; public bool IsSuperAdmin => true;
public bool IsTenantOverride => false; public bool IsTenantOverride => false;
public Guid? UserId => null;
} }
} }

View file

@ -0,0 +1,48 @@
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using foodmarket.Infrastructure.Persistence;
#nullable disable
namespace foodmarket.Infrastructure.Persistence.Migrations
{
/// <summary>Phase8b — org_audit_log: per-tenant журнал мутаций.
///
/// Пишется через OrgAuditInterceptor (SaveChangesInterceptor) на
/// отслеживаемые типы. Tenant-scoped — query-filter ограничивает выборку
/// своей оргой; SuperAdmin без override видит всё. ChangesJson — jsonb
/// формата { "field": { "before": ..., "after": ... } }.</summary>
[DbContext(typeof(AppDbContext))]
[Migration("20260528210000_Phase8b_OrgAuditLog")]
public partial class Phase8b_OrgAuditLog : Migration
{
protected override void Up(MigrationBuilder b)
{
b.Sql(@"
CREATE TABLE IF NOT EXISTS public.org_audit_log (
""Id"" uuid PRIMARY KEY,
""OrganizationId"" uuid NOT NULL,
""UserId"" uuid,
""Action"" varchar(20) NOT NULL,
""EntityType"" varchar(100) NOT NULL,
""EntityId"" uuid,
""ChangesJson"" jsonb NOT NULL,
""CreatedAt"" timestamp with time zone NOT NULL,
""UpdatedAt"" timestamp with time zone
);
CREATE INDEX IF NOT EXISTS ""IX_org_audit_log_OrganizationId_CreatedAt""
ON public.org_audit_log (""OrganizationId"", ""CreatedAt"");
CREATE INDEX IF NOT EXISTS ""IX_org_audit_log_OrganizationId_EntityType_EntityId""
ON public.org_audit_log (""OrganizationId"", ""EntityType"", ""EntityId"");
CREATE INDEX IF NOT EXISTS ""IX_org_audit_log_OrganizationId_UserId_CreatedAt""
ON public.org_audit_log (""OrganizationId"", ""UserId"", ""CreatedAt"");
");
}
protected override void Down(MigrationBuilder b)
{
b.Sql(@"DROP TABLE IF EXISTS public.org_audit_log;");
}
}
}

View file

@ -0,0 +1,132 @@
using System.Text.Json;
using foodmarket.Application.Common.Tenancy;
using foodmarket.Domain.Catalog;
using foodmarket.Domain.Organizations;
using foodmarket.Domain.Purchases;
using foodmarket.Domain.Sales;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.ChangeTracking;
using Microsoft.EntityFrameworkCore.Diagnostics;
namespace foodmarket.Infrastructure.Persistence;
/// <summary>EF Core <c>SaveChangesInterceptor</c>, который снимает diff
/// отслеживаемых сущностей и пишет строки <see cref="OrgAuditLog"/>.
///
/// Срабатывает в <c>SavingChanges</c> (до commit): мы не плодим лог для
/// rolled-back изменений. Лог записывается в тот же DbContext в коллекции
/// будущей SaveChanges'ов — НЕ как отдельный round-trip; одна транзакция,
/// одна атомарность. Параметр <c>SkipAudit</c> позволяет отключить
/// инструментацию для специфических операций (seed, миграция).
///
/// Отслеживаемые типы — белый список (см. <c>IsTracked</c>); расширять
/// сюда, не через рефлексию по всем DbSet'ам, чтобы аудит-лог не
/// раздувался самой меняющей его строкой.</summary>
public sealed class OrgAuditInterceptor : SaveChangesInterceptor
{
private readonly ITenantContext _tenant;
public OrgAuditInterceptor(ITenantContext tenant) => _tenant = tenant;
public override InterceptionResult<int> SavingChanges(
DbContextEventData eventData, InterceptionResult<int> result)
{
WriteAuditEntries(eventData.Context);
return base.SavingChanges(eventData, result);
}
public override ValueTask<InterceptionResult<int>> SavingChangesAsync(
DbContextEventData eventData, InterceptionResult<int> result, CancellationToken ct = default)
{
WriteAuditEntries(eventData.Context);
return base.SavingChangesAsync(eventData, result, ct);
}
private void WriteAuditEntries(DbContext? ctx)
{
if (ctx is not AppDbContext db) return;
if (db.SkipAudit) return;
// Аудитные записи не аудируем (бесконечная рекурсия).
var entries = db.ChangeTracker.Entries()
.Where(e => e.State is EntityState.Added or EntityState.Modified or EntityState.Deleted)
.Where(e => IsTracked(e.Entity))
.ToList();
if (entries.Count == 0) return;
var orgId = _tenant.OrganizationId;
if (orgId is null) return; // системные операции (SuperAdmin без override / seed)
var userId = _tenant.UserId;
foreach (var entry in entries)
{
var (action, diff) = ComputeDiff(entry);
// Diff может быть пустым (Update без реальных изменений) — пропускаем.
if (action == "update" && diff.Count == 0) continue;
var id = entry.Properties.FirstOrDefault(p => p.Metadata.IsPrimaryKey())?.CurrentValue as Guid?;
db.Add(new OrgAuditLog
{
OrganizationId = orgId.Value,
UserId = userId,
Action = action,
EntityType = entry.Entity.GetType().Name,
EntityId = id,
ChangesJson = JsonSerializer.Serialize(diff),
});
}
}
private static bool IsTracked(object entity) => entity switch
{
Supply or SupplyLine => true,
SupplierReturn or SupplierReturnLine => true,
RetailSale or RetailSaleLine => true,
Demand or DemandLine => true,
Product or ProductPrice or ProductBarcode => true,
Counterparty => true,
_ => false,
};
private static (string Action, Dictionary<string, object> Diff) ComputeDiff(EntityEntry entry)
{
var diff = new Dictionary<string, object>();
switch (entry.State)
{
case EntityState.Added:
foreach (var p in entry.Properties)
{
if (IsSkippable(p.Metadata.Name)) continue;
if (p.CurrentValue is null) continue;
diff[p.Metadata.Name] = new { after = p.CurrentValue };
}
return ("create", diff);
case EntityState.Modified:
foreach (var p in entry.Properties)
{
if (IsSkippable(p.Metadata.Name)) continue;
if (!p.IsModified) continue;
if (Equals(p.OriginalValue, p.CurrentValue)) continue;
diff[p.Metadata.Name] = new { before = p.OriginalValue, after = p.CurrentValue };
}
return ("update", diff);
case EntityState.Deleted:
foreach (var p in entry.Properties)
{
if (IsSkippable(p.Metadata.Name)) continue;
if (p.OriginalValue is null) continue;
diff[p.Metadata.Name] = new { before = p.OriginalValue };
}
return ("delete", diff);
default:
return ("noop", diff);
}
}
/// <summary>Поля, которые не имеет смысла включать в diff — служебные
/// и шумные. Tenant, timestamps, навигационные FK дублируются с самими
/// сущностями, а CreatedAt/UpdatedAt меняются при каждом сохранении.</summary>
private static bool IsSkippable(string name) => name switch
{
"OrganizationId" or "CreatedAt" or "UpdatedAt" => true,
_ => false,
};
}

View file

@ -40,6 +40,7 @@ import { SupplierReturnsPage } from '@/pages/SupplierReturnsPage'
import { SupplierReturnEditPage } from '@/pages/SupplierReturnEditPage' import { SupplierReturnEditPage } from '@/pages/SupplierReturnEditPage'
import { DemandsPage } from '@/pages/DemandsPage' import { DemandsPage } from '@/pages/DemandsPage'
import { DemandEditPage } from '@/pages/DemandEditPage' import { DemandEditPage } from '@/pages/DemandEditPage'
import { OrgAuditLogPage } from '@/pages/OrgAuditLogPage'
import { SalesReportPage } from '@/pages/SalesReportPage' import { SalesReportPage } from '@/pages/SalesReportPage'
import { StockReportPage } from '@/pages/StockReportPage' import { StockReportPage } from '@/pages/StockReportPage'
import { ProfitReportPage } from '@/pages/ProfitReportPage' import { ProfitReportPage } from '@/pages/ProfitReportPage'
@ -141,6 +142,7 @@ export default function App() {
<Route path="/sales/demands" element={<DemandsPage />} /> <Route path="/sales/demands" element={<DemandsPage />} />
<Route path="/sales/demands/new" element={<DemandEditPage />} /> <Route path="/sales/demands/new" element={<DemandEditPage />} />
<Route path="/sales/demands/:id" element={<DemandEditPage />} /> <Route path="/sales/demands/:id" element={<DemandEditPage />} />
<Route path="/audit-log" element={<RoleGuard roles={['Admin']}><OrgAuditLogPage /></RoleGuard>} />
<Route path="/admin/import/moysklad" element={<RoleGuard roles={['Admin']}><MoySkladImportPage /></RoleGuard>} /> <Route path="/admin/import/moysklad" element={<RoleGuard roles={['Admin']}><MoySkladImportPage /></RoleGuard>} />
<Route path="/settings/organization" element={<RoleGuard roles={['Admin']}><OrganizationSettingsPage /></RoleGuard>} /> <Route path="/settings/organization" element={<RoleGuard roles={['Admin']}><OrganizationSettingsPage /></RoleGuard>} />
<Route path="/settings/employees" element={<RoleGuard roles={['Admin']}><EmployeesPage /></RoleGuard>} /> <Route path="/settings/employees" element={<RoleGuard roles={['Admin']}><EmployeesPage /></RoleGuard>} />

View file

@ -7,7 +7,7 @@ import { cn } from '@/lib/utils'
import { import {
LayoutDashboard, Package, FolderTree, Ruler, Tag, LayoutDashboard, Package, FolderTree, Ruler, Tag,
Users, Warehouse, Store as StoreIcon, LogOut, Download, UserCog, Shield, ShieldCheck, Users, Warehouse, Store as StoreIcon, LogOut, Download, UserCog, Shield, ShieldCheck,
Boxes, History, TruckIcon, ShoppingCart, Settings, Menu, X, PackagePlus, PackageMinus, ArrowRightLeft, ClipboardCheck, Undo2, BarChart3, TrendingUp, Target, Send, Boxes, History, TruckIcon, ShoppingCart, Settings, Menu, X, PackagePlus, PackageMinus, ArrowRightLeft, ClipboardCheck, Undo2, BarChart3, TrendingUp, Target, Send, FileText,
} from 'lucide-react' } from 'lucide-react'
import { Logo } from './Logo' import { Logo } from './Logo'
import { SuperAdminAsOrgBanner } from './SuperAdminAsOrgBanner' import { SuperAdminAsOrgBanner } from './SuperAdminAsOrgBanner'
@ -129,6 +129,7 @@ function buildNav(roles: string[]): NavSection[] {
{ to: '/catalog/retail-points', icon: StoreIcon, label: 'Кассы' }, { to: '/catalog/retail-points', icon: StoreIcon, label: 'Кассы' },
{ to: '/settings/employees', icon: UserCog, label: 'Сотрудники' }, { to: '/settings/employees', icon: UserCog, label: 'Сотрудники' },
{ to: '/settings/employee-roles', icon: Shield, label: 'Роли' }, { to: '/settings/employee-roles', icon: Shield, label: 'Роли' },
{ to: '/audit-log', icon: FileText, label: 'Журнал изменений' },
]}) ]})
} }

View file

@ -0,0 +1,125 @@
import { useState } from 'react'
import { useQuery } from '@tanstack/react-query'
import { api } from '@/lib/api'
import { ListPageShell } from '@/components/ListPageShell'
import { DataTable } from '@/components/DataTable'
import { Pagination } from '@/components/Pagination'
import { SearchBar } from '@/components/SearchBar'
import { Field, Select } from '@/components/Field'
import type { PagedResult } from '@/lib/types'
interface AuditRow {
id: string
createdAt: string
userId: string | null
userName: string | null
action: string
entityType: string
entityId: string | null
changesJson: string
}
const ACTIONS = [
{ value: '', label: 'Все' },
{ value: 'create', label: 'Создание' },
{ value: 'update', label: 'Изменение' },
{ value: 'delete', label: 'Удаление' },
]
const ENTITY_TYPES = [
{ value: '', label: 'Все' },
{ value: 'Supply', label: 'Приёмки' },
{ value: 'SupplierReturn', label: 'Возвраты поставщикам' },
{ value: 'RetailSale', label: 'Чеки розничные' },
{ value: 'Demand', label: 'Оптовые отгрузки' },
{ value: 'Product', label: 'Товары' },
{ value: 'Counterparty', label: 'Контрагенты' },
]
/** Журнал мутаций tenant'а кто, что и когда менял. Read-only.
* Запись делает OrgAuditInterceptor автоматически на каждом SaveChanges. */
export function OrgAuditLogPage() {
const [page, setPage] = useState(1)
const [entityType, setEntityType] = useState('')
const [action, setAction] = useState('')
const [search, setSearch] = useState('')
const params = new URLSearchParams({ page: String(page), pageSize: '50' })
if (entityType) params.set('entityType', entityType)
if (action) params.set('action', action)
const rep = useQuery({
queryKey: ['audit-log', page, entityType, action],
queryFn: async () => (await api.get<PagedResult<AuditRow>>(`/api/admin/audit-log?${params}`)).data,
placeholderData: (prev) => prev,
})
const filtered = (rep.data?.items ?? []).filter((r) => {
if (!search) return true
const s = search.toLowerCase()
return (r.userName ?? '').toLowerCase().includes(s)
|| r.entityType.toLowerCase().includes(s)
|| r.changesJson.toLowerCase().includes(s)
})
return (
<ListPageShell
title="Журнал изменений"
description={rep.data ? `${rep.data.total.toLocaleString('ru')} событий` : 'Все мутации документов и справочников.'}
actions={
<>
<SearchBar value={search} onChange={setSearch} placeholder="По имени, типу, тексту…" />
</>
}
footer={rep.data && rep.data.total > 0 && (
<Pagination page={page} pageSize={rep.data.pageSize} total={rep.data.total} onPageChange={setPage} />
)}
>
<div className="flex gap-3 mb-3 max-w-2xl">
<Field label="Тип сущности">
<Select value={entityType} onChange={(e) => { setEntityType(e.target.value); setPage(1) }}>
{ENTITY_TYPES.map((t) => <option key={t.value} value={t.value}>{t.label}</option>)}
</Select>
</Field>
<Field label="Действие">
<Select value={action} onChange={(e) => { setAction(e.target.value); setPage(1) }}>
{ACTIONS.map((a) => <option key={a.value} value={a.value}>{a.label}</option>)}
</Select>
</Field>
</div>
<DataTable
rows={filtered}
isLoading={rep.isLoading}
rowKey={(r) => r.id}
columns={[
{ header: 'Когда', width: '170px', cell: (r) => new Date(r.createdAt).toLocaleString('ru') },
{ header: 'Кто', width: '180px', cell: (r) => r.userName ?? <span className="text-slate-400">система</span> },
{ header: 'Тип', width: '140px', cell: (r) => r.entityType },
{ header: 'Действие', width: '110px', cell: (r) => (
<span className={`text-xs px-2 py-0.5 rounded ${
r.action === 'create' ? 'bg-green-50 text-green-700' :
r.action === 'delete' ? 'bg-red-50 text-red-700' :
'bg-slate-100 text-slate-700'
}`}>{r.action}</span>
)},
{ header: 'EntityId', width: '110px', cell: (r) => (
r.entityId ? <span className="font-mono text-xs text-slate-500">{r.entityId.slice(0, 8)}</span> : '—'
)},
{ header: 'Изменения', cell: (r) => (
<details className="text-xs">
<summary className="cursor-pointer text-slate-500 hover:text-slate-700">показать diff</summary>
<pre className="mt-1 p-2 bg-slate-50 dark:bg-slate-800 rounded text-[10px] max-w-2xl overflow-x-auto">{
(() => {
try { return JSON.stringify(JSON.parse(r.changesJson), null, 2) }
catch { return r.changesJson }
})()
}</pre>
</details>
)},
]}
empty="Событий пока нет."
/>
</ListPageShell>
)
}

View file

@ -0,0 +1,83 @@
using System.Net.Http.Json;
using System.Text.Json;
using FluentAssertions;
using foodmarket.IntegrationTests.Support;
using Xunit;
namespace foodmarket.IntegrationTests;
[Collection(ApiCollection.Name)]
public class OrgAuditLogTests
{
private readonly ApiFactory _factory;
public OrgAuditLogTests(ApiFactory factory) => _factory = factory;
private static string RandomBarcode()
=> string.Concat(Enumerable.Range(0, 13).Select(_ => Random.Shared.Next(0, 10)));
[Fact]
public async Task Product_create_writes_audit_entry()
{
var api = new ApiActor(_factory.CreateClient());
await api.SignupAndLoginAsync($"audit-{Guid.NewGuid():N}");
var refs = await api.LoadRefsAsync();
var pid = await api.CreateProductAsync(refs, $"AUD-{Guid.NewGuid():N}", 100m, RandomBarcode());
var log = await api.GetJsonAsync("/api/admin/audit-log?entityType=Product&pageSize=20");
var items = log.GetProperty("items").EnumerateArray().ToList();
var entry = items.First(x => x.GetProperty("entityId").GetString() == pid);
entry.GetProperty("action").GetString().Should().Be("create");
entry.GetProperty("entityType").GetString().Should().Be("Product");
entry.GetProperty("changesJson").GetString().Should().Contain("\"after\"");
}
[Fact]
public async Task Counterparty_update_diff_contains_before_after()
{
var api = new ApiActor(_factory.CreateClient());
await api.SignupAndLoginAsync($"audit-upd-{Guid.NewGuid():N}");
var cid = await api.CreateCounterpartyAsync($"Init-{Guid.NewGuid():N}");
// Обновляем — меняем имя.
var newName = $"Renamed-{Guid.NewGuid():N}";
var resp = await api.Http.PutAsJsonAsync($"/api/catalog/counterparties/{cid}", new
{
name = newName, legalName = (string?)null, type = 0,
bin = "987654321098", iin = (string?)null, taxNumber = (string?)null,
countryId = (string?)null, address = "Алматы", phone = "+77000000000",
email = "x@y.kz", bankName = (string?)null, bankAccount = (string?)null,
bik = (string?)null, contactPerson = "X", notes = (string?)null,
});
resp.EnsureSuccessStatusCode();
var log = await api.GetJsonAsync($"/api/admin/audit-log?entityType=Counterparty&entityId={cid}");
var items = log.GetProperty("items").EnumerateArray().ToList();
items.Should().Contain(x => x.GetProperty("action").GetString() == "update");
var update = items.First(x => x.GetProperty("action").GetString() == "update");
var diff = update.GetProperty("changesJson").GetString()!;
diff.Should().Contain("\"before\"");
diff.Should().Contain("\"after\"");
diff.Should().Contain(newName);
}
[Fact]
public async Task Tenant_isolation_audit_log()
{
var a = new ApiActor(_factory.CreateClient());
var b = new ApiActor(_factory.CreateClient());
await a.SignupAndLoginAsync($"audit-iso-a-{Guid.NewGuid():N}");
await b.SignupAndLoginAsync($"audit-iso-b-{Guid.NewGuid():N}");
var refsA = await a.LoadRefsAsync();
var pidA = await a.CreateProductAsync(refsA, $"P-{Guid.NewGuid():N}", 100m, RandomBarcode());
// A видит свою запись.
var logA = await a.GetJsonAsync($"/api/admin/audit-log?entityId={pidA}");
logA.GetProperty("items").EnumerateArray().Should().Contain(
x => x.GetProperty("entityId").GetString() == pidA);
// B не видит чужую запись (query-filter по OrganizationId).
var logB = await b.GetJsonAsync($"/api/admin/audit-log?entityId={pidA}");
logB.GetProperty("items").EnumerateArray().Should().BeEmpty();
}
}

View file

@ -10,4 +10,5 @@ public sealed class FakeTenantContext : ITenantContext
public bool IsAuthenticated { get; set; } = true; public bool IsAuthenticated { get; set; } = true;
public bool IsSuperAdmin { get; set; } public bool IsSuperAdmin { get; set; }
public bool IsTenantOverride { get; set; } public bool IsTenantOverride { get; set; }
public Guid? UserId { get; set; }
} }