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))); } }