using Microsoft.EntityFrameworkCore; /// /// Provides cursor-paginated access to the audit trail of digitization events /// for a given batch. Events are ordered by occurred_at ascending with id /// as a tie-breaker for events at the same timestamp. /// public class BatchEventService : IBatchEventService { private readonly AppDbContext _db; private const int MaxPageSize = 200; private const int DefaultPageSize = 50; public BatchEventService(AppDbContext db) { _db = db; } public async Task> GetEventsAsync( Guid batchId, DateTimeOffset? after, int pageSize) { // Validate batch exists var batchExists = await _db.DigitizationBatches .AnyAsync(b => b.Id == batchId); if (!batchExists) throw new NotFoundException("Batch not found.", "BATCH_NOT_FOUND"); // Clamp page size pageSize = Math.Clamp(pageSize, 1, MaxPageSize); // Build query var query = _db.DigitizationEvents .AsNoTracking() .Include(e => e.Actor) .Where(e => e.BatchId == batchId); // Apply cursor filter — only events strictly after the cursor timestamp if (after.HasValue) { query = query.Where(e => e.OccurredAt > after.Value); } // Fetch pageSize+1 rows to determine HasMore without a COUNT query var events = await query .OrderBy(e => e.OccurredAt) .ThenBy(e => e.Id) // tie-breaker for events at the same timestamp .Take(pageSize + 1) .Select(e => new BatchEventResponse( e.Id, e.BatchId, e.EventType.ToDbString(), e.ActorUserId, e.Actor.Username, e.Actor.FullName, e.OccurredAt, e.MetadataJson)) .ToListAsync(); var hasMore = events.Count > pageSize; var page = hasMore ? events.Take(pageSize).ToList() : events; // Build next cursor from the last item's OccurredAt string? nextCursor = null; if (hasMore && page.Count > 0) { var lastEvent = page[^1]; // ISO-8601 round-trip format preserves full precision nextCursor = lastEvent.OccurredAt.ToString("o"); } return new CursorPagedResult( Items: page, PageSize: pageSize, NextCursor: nextCursor, HasMore: hasMore); } }