Event History & Date Range Filtering
With the POST endpoint in place, we need a way to retrieve the full tracking history for a parcel. This GET endpoint returns all events in chronological order and supports filtering by date range through query parameters.
The GET Endpoint
The event history endpoint follows the same nested resource pattern:
GET /api/parcels/{parcelId}/events
This returns all tracking events for the specified parcel, ordered by timestamp ascending (oldest first). Chronological order is natural for a timeline --- you read it from top to bottom, earliest to latest.
csharp1[HttpGet]2public async Task<ActionResult<IEnumerable<TrackingEventResponse>>> GetEventHistory(3 Guid parcelId,4 [FromQuery] DateTime? from,5 [FromQuery] DateTime? to)6{7 // Implementation follows...8}
The from and to parameters are bound from query string parameters using [FromQuery]. They are nullable because filtering is optional --- when omitted, the endpoint returns the full history.
Step 1: Verify the Parcel Exists
Same as the POST endpoint, we first check that the parcel exists:
csharp1var parcelExists = await _context.Parcels2 .AnyAsync(p => p.Id == parcelId);34if (!parcelExists)5{6 return NotFound(new ProblemDetails7 {8 Title = "Parcel not found",9 Detail = $"No parcel exists with ID {parcelId}",10 Status = 40411 });12}
Notice a difference from the POST endpoint: here we use AnyAsync instead of FirstOrDefaultAsync. We do not need the parcel entity itself --- we only need to know if it exists. AnyAsync generates a more efficient SQL query (SELECT EXISTS(...) or SELECT TOP 1 1 ...).
Step 2: Build the Query with Optional Filters
We start with the base query and conditionally apply date filters:
csharp1var query = _context.TrackingEvents2 .Where(e => e.ParcelId == parcelId)3 .AsQueryable();45if (from.HasValue)6{7 query = query.Where(e => e.Timestamp >= from.Value);8}910if (to.HasValue)11{12 query = query.Where(e => e.Timestamp <= to.Value);13}
This approach uses query composition --- building up the LINQ query incrementally before executing it. The key insight is that LINQ queries are not executed when they are defined. They are only executed when you enumerate the results (e.g., with ToListAsync).
Why Conditional Filtering Works
Each .Where() call adds a filter to the expression tree. EF Core combines all the filters into a single SQL WHERE clause:
sql1-- No filters2SELECT * FROM TrackingEvents WHERE ParcelId = 1 ORDER BY Timestamp34-- With both filters5SELECT * FROM TrackingEvents6WHERE ParcelId = 1 AND Timestamp >= '2024-03-01' AND Timestamp <= '2024-03-31'7ORDER BY Timestamp
This pattern avoids complex conditional SQL and keeps the C# code readable. You add filters only when the corresponding parameter has a value.
Step 3: Execute and Map
After building the query, we order by timestamp and project to the response DTO:
csharp1var events = await query2 .OrderBy(e => e.Timestamp)3 .Select(e => new TrackingEventResponse4 {5 Id = e.Id,6 ParcelId = e.ParcelId,7 Timestamp = e.Timestamp,8 EventType = e.EventType.ToString(),9 Description = e.Description,10 LocationCity = e.LocationCity,11 LocationState = e.LocationState,12 LocationCountry = e.LocationCountry,13 DelayReason = e.DelayReason14 })15 .ToListAsync();1617return Ok(events);
Using Select to project directly to the response DTO has two benefits:
- Efficiency: EF Core only queries the columns it needs (though in this case we use all columns)
- Separation: The database entity shape does not leak into the API response
The EventType.ToString() in the Select is evaluated client-side after EF Core fetches the results. EF Core fetches the enum as an integer and the .ToString() converts it to the name.
The Complete GET Endpoint
csharp1[HttpGet]2public async Task<ActionResult<IEnumerable<TrackingEventResponse>>> GetEventHistory(3 Guid parcelId,4 [FromQuery] DateTime? from,5 [FromQuery] DateTime? to)6{7 var parcelExists = await _context.Parcels8 .AnyAsync(p => p.Id == parcelId);910 if (!parcelExists)11 return NotFound(new ProblemDetails12 {13 Title = "Parcel not found",14 Detail = $"No parcel exists with ID {parcelId}",15 Status = 40416 });1718 var query = _context.TrackingEvents19 .Where(e => e.ParcelId == parcelId)20 .AsQueryable();2122 if (from.HasValue)23 query = query.Where(e => e.Timestamp >= from.Value);2425 if (to.HasValue)26 query = query.Where(e => e.Timestamp <= to.Value);2728 var events = await query29 .OrderBy(e => e.Timestamp)30 .Select(e => new TrackingEventResponse31 {32 Id = e.Id,33 ParcelId = e.ParcelId,34 Timestamp = e.Timestamp,35 EventType = e.EventType.ToString(),36 Description = e.Description,37 LocationCity = e.LocationCity,38 LocationState = e.LocationState,39 LocationCountry = e.LocationCountry,40 DelayReason = e.DelayReason41 })42 .ToListAsync();4344 return Ok(events);45}
Query Parameter Formatting
Clients pass date filters as query string parameters:
GET /api/parcels/f0e1d2c3-b4a5-6789-0abc-def123456789/events?from=2024-03-01T00:00:00Z&to=2024-03-31T23:59:59Z
ASP.NET Core automatically parses ISO 8601 date strings into DateTime values. The [FromQuery] attribute tells the model binder to look in the query string for these parameters.
Common request variations:
1# Full history (no filters)2GET /api/parcels/f0e1d2c3-b4a5-6789-0abc-def123456789/events34# Events after a specific date5GET /api/parcels/f0e1d2c3-b4a5-6789-0abc-def123456789/events?from=2024-03-15T00:00:00Z67# Events before a specific date8GET /api/parcels/f0e1d2c3-b4a5-6789-0abc-def123456789/events?to=2024-03-20T23:59:59Z910# Events within a date range11GET /api/parcels/f0e1d2c3-b4a5-6789-0abc-def123456789/events?from=2024-03-15T00:00:00Z&to=2024-03-20T23:59:59Z
All four variations are handled by the same endpoint. When from or to is null, the corresponding filter is simply not applied.
Validating Date Range Parameters
What if the client provides a from date that is after the to date? We should catch this and return a helpful error:
csharp1if (from.HasValue && to.HasValue && from.Value > to.Value)2{3 return BadRequest(new ProblemDetails4 {5 Title = "Invalid date range",6 Detail = "'from' date must be earlier than or equal to 'to' date.",7 Status = 4008 });9}
Add this check before building the query. It prevents executing a query that would always return zero results, and gives the client a clear error message.
Testing with curl
Fetching the full history:
bash1curl http://localhost:5000/api/parcels/f0e1d2c3-b4a5-6789-0abc-def123456789/events
Expected response:
json1[2 {3 "id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",4 "parcelId": "f0e1d2c3-b4a5-6789-0abc-def123456789",5 "timestamp": "2024-03-15T10:30:00Z",6 "eventType": "PickedUp",7 "description": "Package picked up from sender",8 "locationCity": "Chicago",9 "locationState": "IL",10 "locationCountry": "US",11 "delayReason": null12 },13 {14 "id": "b2c3d4e5-f6a7-8901-bcde-f12345678901",15 "parcelId": "f0e1d2c3-b4a5-6789-0abc-def123456789",16 "timestamp": "2024-03-15T14:00:00Z",17 "eventType": "Departed",18 "description": "Left Chicago sorting facility",19 "locationCity": "Chicago",20 "locationState": "IL",21 "locationCountry": "US",22 "delayReason": null23 },24 {25 "id": "c3d4e5f6-a7b8-9012-cdef-123456789012",26 "parcelId": "f0e1d2c3-b4a5-6789-0abc-def123456789",27 "timestamp": "2024-03-15T20:15:00Z",28 "eventType": "Arrived",29 "description": "Arrived at Indianapolis hub",30 "locationCity": "Indianapolis",31 "locationState": "IN",32 "locationCountry": "US",33 "delayReason": null34 }35]
Filtering by date range:
bash1curl "http://localhost:5000/api/parcels/f0e1d2c3-b4a5-6789-0abc-def123456789/events?from=2024-03-15T12:00:00Z&to=2024-03-15T18:00:00Z"
This returns only the "Departed" event, since it is the only one within the 12:00-18:00 window.
Empty Results
When the parcel exists but has no events (or no events match the filter), the endpoint returns 200 OK with an empty array:
json1[]
This is correct REST behavior. A 404 would mean the parcel does not exist, while an empty 200 means the parcel exists but has no matching events. The distinction matters for clients.
Performance Considerations
For parcels with many events, consider adding a database index on the Timestamp column:
csharp1protected override void OnModelCreating(ModelBuilder modelBuilder)2{3 modelBuilder.Entity<TrackingEvent>()4 .HasIndex(e => new { e.ParcelId, e.Timestamp });5}
A composite index on (ParcelId, Timestamp) covers both the filtering and ordering operations. EF Core generates:
sql1CREATE INDEX IX_TrackingEvents_ParcelId_Timestamp2ON TrackingEvents (ParcelId, Timestamp);
This index benefits:
- The POST endpoint's query for the latest event (
WHERE ParcelId = X ORDER BY Timestamp DESC LIMIT 1) - The GET endpoint's filtered timeline query (
WHERE ParcelId = X AND Timestamp >= Y ORDER BY Timestamp)
Complete Controller
Here is the full TrackingEventsController with both endpoints:
csharp1[ApiController]2[Route("api/parcels/{parcelId}/events")]3public class TrackingEventsController : ControllerBase4{5 private readonly ParcelTrackingDbContext _context;67 public TrackingEventsController(ParcelTrackingDbContext context)8 {9 _context = context;10 }1112 [HttpPost]13 public async Task<ActionResult<TrackingEventResponse>> Create(14 Guid parcelId,15 CreateTrackingEventRequest request)16 {17 var parcel = await _context.Parcels18 .FirstOrDefaultAsync(p => p.Id == parcelId);1920 if (parcel is null)21 return NotFound(new ProblemDetails22 {23 Title = "Parcel not found",24 Detail = $"No parcel exists with ID {parcelId}",25 Status = 40426 });2728 var latestEvent = await _context.TrackingEvents29 .Where(e => e.ParcelId == parcelId)30 .OrderByDescending(e => e.Timestamp)31 .FirstOrDefaultAsync();3233 if (latestEvent is not null && request.Timestamp < latestEvent.Timestamp)34 return BadRequest(new ProblemDetails35 {36 Title = "Invalid event timestamp",37 Detail = $"Event timestamp is earlier than the most recent event.",38 Status = 40039 });4041 var trackingEvent = new TrackingEvent42 {43 ParcelId = parcelId,44 Timestamp = request.Timestamp,45 EventType = request.EventType,46 Description = request.Description,47 LocationCity = request.LocationCity,48 LocationState = request.LocationState,49 LocationCountry = request.LocationCountry,50 DelayReason = request.DelayReason51 };5253 _context.TrackingEvents.Add(trackingEvent);5455 parcel.Status = request.EventType switch56 {57 EventType.PickedUp => ParcelStatus.PickedUp,58 EventType.Departed => ParcelStatus.InTransit,59 EventType.Arrived => ParcelStatus.InTransit,60 EventType.InTransit => ParcelStatus.InTransit,61 EventType.OutForDelivery => ParcelStatus.OutForDelivery,62 EventType.DeliveryAttempt => ParcelStatus.OutForDelivery,63 EventType.Delivered => ParcelStatus.Delivered,64 EventType.Exception => ParcelStatus.Exception,65 EventType.Returned => ParcelStatus.Returned,66 _ => parcel.Status67 };6869 await _context.SaveChangesAsync();7071 var response = new TrackingEventResponse72 {73 Id = trackingEvent.Id,74 ParcelId = trackingEvent.ParcelId,75 Timestamp = trackingEvent.Timestamp,76 EventType = trackingEvent.EventType.ToString(),77 Description = trackingEvent.Description,78 LocationCity = trackingEvent.LocationCity,79 LocationState = trackingEvent.LocationState,80 LocationCountry = trackingEvent.LocationCountry,81 DelayReason = trackingEvent.DelayReason82 };8384 return CreatedAtAction(85 nameof(GetEventHistory),86 new { parcelId },87 response);88 }8990 [HttpGet]91 public async Task<ActionResult<IEnumerable<TrackingEventResponse>>> GetEventHistory(92 Guid parcelId,93 [FromQuery] DateTime? from,94 [FromQuery] DateTime? to)95 {96 var parcelExists = await _context.Parcels97 .AnyAsync(p => p.Id == parcelId);9899 if (!parcelExists)100 return NotFound(new ProblemDetails101 {102 Title = "Parcel not found",103 Detail = $"No parcel exists with ID {parcelId}",104 Status = 404105 });106107 if (from.HasValue && to.HasValue && from.Value > to.Value)108 return BadRequest(new ProblemDetails109 {110 Title = "Invalid date range",111 Detail = "'from' date must be earlier than or equal to 'to' date.",112 Status = 400113 });114115 var query = _context.TrackingEvents116 .Where(e => e.ParcelId == parcelId)117 .AsQueryable();118119 if (from.HasValue)120 query = query.Where(e => e.Timestamp >= from.Value);121122 if (to.HasValue)123 query = query.Where(e => e.Timestamp <= to.Value);124125 var events = await query126 .OrderBy(e => e.Timestamp)127 .Select(e => new TrackingEventResponse128 {129 Id = e.Id,130 ParcelId = e.ParcelId,131 Timestamp = e.Timestamp,132 EventType = e.EventType.ToString(),133 Description = e.Description,134 LocationCity = e.LocationCity,135 LocationState = e.LocationState,136 LocationCountry = e.LocationCountry,137 DelayReason = e.DelayReason138 })139 .ToListAsync();140141 return Ok(events);142 }143}
Summary
In this section, you learned how to:
- Build a GET endpoint that returns an ordered event timeline
- Use nullable query parameters for optional date range filtering
- Compose LINQ queries conditionally based on which parameters are present
- Distinguish between
AnyAsync(existence check) andFirstOrDefaultAsync(entity retrieval) - Return
200 OKwith an empty array when no events match, rather than404 - Add a composite index for query performance on
ParcelIdandTimestamp
Together, the POST and GET endpoints form a complete tracking events API that enforces business rules, maintains chronological integrity, and supports flexible querying.