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;
///
/// Read-only access to the immutable POS / management audit trail. Gated by
/// ; branch-scoped sessions only ever see
/// their own branch's entries (enforced by the DB-level branch isolation filter),
/// café-wide owners see everything.
///
[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 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()
: (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(true, items, new PagedMeta(total, page, pageSize)));
}
}