2a24798a59
CI/CD / CI · API (dotnet build + test) (push) Successful in 44s
CI/CD / CI · Admin API (dotnet build) (push) Successful in 31s
CI/CD / CI · Dashboard (tsc) (push) Successful in 1m11s
CI/CD / CI · Admin Web (tsc) (push) Successful in 38s
CI/CD / CI · Website (tsc) (push) Successful in 46s
CI/CD / CI · Koja (tsc) (push) Successful in 50s
CI/CD / Deploy · all services (push) Successful in 3m39s
Logs showed the raw User ID (ActorName was almost never stored) and an English role enum. Now: - AuditController resolves each entry's actor to the employee's CURRENT full name and localized role at read time (joins Employees with IgnoreQueryFilters, so it also names soft-deleted staff and fixes all historical rows — no migration). - The audit table renders "Full name (Role)" with the role localized (fa/en/ar); the name is a button that opens an employee-details dialog. - New EmployeeDetailsDialog: fetches the employee and shows name, role, phone, base salary, and an "Open in HR" link; handles removed staff gracefully. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
118 lines
4.4 KiB
C#
118 lines
4.4 KiB
C#
using Microsoft.AspNetCore.Mvc;
|
|
using Microsoft.EntityFrameworkCore;
|
|
using Meezi.API.Models.Audit;
|
|
using Meezi.Core.Authorization;
|
|
using Meezi.Core.Enums;
|
|
using Meezi.Core.Interfaces;
|
|
using Meezi.Infrastructure.Data;
|
|
using Meezi.Shared;
|
|
|
|
namespace Meezi.API.Controllers;
|
|
|
|
/// <summary>
|
|
/// Read-only access to the immutable POS / management audit trail. Gated by
|
|
/// <see cref="Permission.ViewReports"/>; branch-scoped sessions only ever see
|
|
/// their own branch's entries (enforced by the DB-level branch isolation filter),
|
|
/// café-wide owners see everything.
|
|
/// </summary>
|
|
[Route("api/cafes/{cafeId}/audit-logs")]
|
|
public class AuditController : CafeApiControllerBase
|
|
{
|
|
private const int MaxPageSize = 100;
|
|
|
|
private readonly AppDbContext _db;
|
|
|
|
public AuditController(AppDbContext db)
|
|
{
|
|
_db = db;
|
|
}
|
|
|
|
[HttpGet]
|
|
public async Task<IActionResult> List(
|
|
string cafeId,
|
|
ITenantContext tenant,
|
|
CancellationToken ct,
|
|
[FromQuery] string? category = null,
|
|
[FromQuery] string? action = null,
|
|
[FromQuery] string? branchId = null,
|
|
[FromQuery] string? entityType = null,
|
|
[FromQuery] string? entityId = null,
|
|
[FromQuery] DateTime? from = null,
|
|
[FromQuery] DateTime? to = null,
|
|
[FromQuery] int page = 1,
|
|
[FromQuery] int pageSize = 50)
|
|
{
|
|
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
|
if (EnsurePermission(tenant, Permission.ViewAuditLog) is { } forbidden) return forbidden;
|
|
|
|
if (page < 1) page = 1;
|
|
if (pageSize < 1) pageSize = 50;
|
|
if (pageSize > MaxPageSize) pageSize = MaxPageSize;
|
|
|
|
var query = _db.AuditLogs.AsNoTracking().Where(x => x.CafeId == cafeId);
|
|
|
|
if (!string.IsNullOrWhiteSpace(category))
|
|
query = query.Where(x => x.Category == category);
|
|
if (!string.IsNullOrWhiteSpace(action))
|
|
query = query.Where(x => x.Action == action);
|
|
if (!string.IsNullOrWhiteSpace(branchId))
|
|
query = query.Where(x => x.BranchId == branchId);
|
|
if (!string.IsNullOrWhiteSpace(entityType))
|
|
query = query.Where(x => x.EntityType == entityType);
|
|
if (!string.IsNullOrWhiteSpace(entityId))
|
|
query = query.Where(x => x.EntityId == entityId);
|
|
if (from is { } f)
|
|
query = query.Where(x => x.CreatedAt >= f);
|
|
if (to is { } t)
|
|
query = query.Where(x => x.CreatedAt <= t);
|
|
|
|
var total = await query.CountAsync(ct);
|
|
|
|
var rows = await query
|
|
.OrderByDescending(x => x.CreatedAt)
|
|
.Skip((page - 1) * pageSize)
|
|
.Take(pageSize)
|
|
.Select(x => new
|
|
{
|
|
x.Id, x.Category, x.Action, x.EntityType, x.EntityId, x.BranchId,
|
|
x.ActorId, x.ActorName, x.ActorRole, x.Summary, x.DetailsJson, x.CreatedAt
|
|
})
|
|
.ToListAsync(ct);
|
|
|
|
// Resolve the actor's CURRENT full name + role from the employee record.
|
|
// This fixes historical rows (where ActorName was never stored) and keeps
|
|
// names current. IgnoreQueryFilters so we still name soft-deleted staff.
|
|
var actorIds = rows
|
|
.Where(r => !string.IsNullOrEmpty(r.ActorId))
|
|
.Select(r => r.ActorId!)
|
|
.Distinct()
|
|
.ToList();
|
|
|
|
var employees = actorIds.Count == 0
|
|
? new Dictionary<string, (string Name, EmployeeRole Role)>()
|
|
: (await _db.Employees
|
|
.IgnoreQueryFilters()
|
|
.AsNoTracking()
|
|
.Where(e => e.CafeId == cafeId && actorIds.Contains(e.Id))
|
|
.Select(e => new { e.Id, e.Name, e.Role })
|
|
.ToListAsync(ct))
|
|
.ToDictionary(e => e.Id, e => (e.Name, e.Role));
|
|
|
|
var items = rows.Select(r =>
|
|
{
|
|
string? name = r.ActorName;
|
|
string? role = r.ActorRole;
|
|
if (!string.IsNullOrEmpty(r.ActorId) && employees.TryGetValue(r.ActorId, out var emp))
|
|
{
|
|
name = emp.Name; // prefer the live employee name
|
|
role ??= emp.Role.ToString();
|
|
}
|
|
return new AuditLogDto(
|
|
r.Id, r.Category, r.Action, r.EntityType, r.EntityId, r.BranchId,
|
|
r.ActorId, name, role, r.Summary, r.DetailsJson, r.CreatedAt);
|
|
}).ToList();
|
|
|
|
return Ok(new PagedApiResponse<AuditLogDto>(true, items, new PagedMeta(total, page, pageSize)));
|
|
}
|
|
}
|