Skip to content

Commit c1012c1

Browse files
committed
feat: all shocker logs pagination
1 parent 0235cd2 commit c1012c1

5 files changed

Lines changed: 264 additions & 27 deletions

File tree

API/Controller/Shockers/GetShockerLogs.cs

Lines changed: 40 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -8,13 +8,28 @@
88
using OpenShock.Common.Errors;
99
using OpenShock.Common.Extensions;
1010
using OpenShock.Common.Models;
11+
using OpenShock.Common.OpenShockDb;
1112
using OpenShock.Common.Problems;
1213
using OpenShock.Common.Utils;
14+
using OpenShock.Common.Utils.Pagination;
1315

1416
namespace OpenShock.API.Controller.Shockers;
1517

1618
public sealed partial class ShockerController
1719
{
20+
private const string AllLogsDefaultSort = "createdOn";
21+
22+
private static readonly IReadOnlyDictionary<string, SortFunc<ShockerControlLog>> AllLogsSorters =
23+
new Dictionary<string, SortFunc<ShockerControlLog>>(StringComparer.OrdinalIgnoreCase)
24+
{
25+
[AllLogsDefaultSort] = (q, desc) => desc ? q.OrderByDescending(x => x.CreatedAt) : q.OrderBy(x => x.CreatedAt),
26+
["intensity"] = (q, desc) => desc ? q.OrderByDescending(x => x.Intensity) : q.OrderBy(x => x.Intensity),
27+
["duration"] = (q, desc) => desc ? q.OrderByDescending(x => x.Duration) : q.OrderBy(x => x.Duration),
28+
["type"] = (q, desc) => desc ? q.OrderByDescending(x => x.Type) : q.OrderBy(x => x.Type),
29+
["hubName"] = (q, desc) => desc ? q.OrderByDescending(x => x.Shocker.Device.Name) : q.OrderBy(x => x.Shocker.Device.Name),
30+
["shockerName"] = (q, desc) => desc ? q.OrderByDescending(x => x.Shocker.Name) : q.OrderBy(x => x.Shocker.Name),
31+
};
32+
1833
/// <summary>
1934
/// Get the logs for a shocker
2035
/// </summary>
@@ -69,26 +84,33 @@ public async Task<IActionResult> GetShockerLogs([FromRoute] Guid shockerId, [Fro
6984

7085

7186
/// <summary>
72-
/// Get the logs for all shockers
87+
/// Get a paged set of control logs across the caller's shockers.
7388
/// </summary>
74-
/// <param name="offset"></param>
75-
/// <param name="limit"></param>
76-
/// <response code="200">The logs</response>
77-
/// <response code="404">Shocker does not exist</response>
89+
/// <param name="pagination">Page, sort, and search parameters. Supported sort keys: createdOn (default), intensity, duration, type, hubName, shockerName.</param>
90+
/// <param name="shockerIds">Optional shocker ID filter. When omitted or empty, logs for all of the caller's shockers are returned.</param>
91+
/// <param name="cancellationToken"></param>
92+
/// <response code="200">A page of logs.</response>
7893
[HttpGet("logs")]
7994
[EnableRateLimiting("shocker-logs")]
80-
[ProducesResponseType<ShockerLogsResponse>(StatusCodes.Status200OK, MediaTypeNames.Application.Json)]
81-
[ProducesResponseType<OpenShockProblem>(StatusCodes.Status404NotFound, MediaTypeNames.Application.ProblemJson)]
95+
[ProducesResponseType<PagedResult<LogEntryWithHub>>(StatusCodes.Status200OK, MediaTypeNames.Application.Json)]
8296
[MapToApiVersion("1")]
83-
public async Task<IActionResult> GetAllShockerLogs([FromQuery(Name = "offset")] uint offset = 0,
84-
[FromQuery, Range(1, 500)] uint limit = 100)
97+
public async Task<PagedResult<LogEntryWithHub>> GetAllShockerLogs(
98+
[FromQuery] PaginationQuery pagination,
99+
[FromQuery(Name = "shockerIds")] Guid[]? shockerIds,
100+
CancellationToken cancellationToken)
85101
{
86-
var logs = await _db.ShockerControlLogs
87-
.Where(x => x.Shocker.Device.OwnerId == CurrentUser.Id)
88-
.OrderByDescending(x => x.CreatedAt)
89-
.Skip((int)offset)
90-
.Take((int)limit)
91-
.Select(x => new LogEntryWithHub
102+
var query = _db.ShockerControlLogs
103+
.Where(x => x.Shocker.Device.OwnerId == CurrentUser.Id);
104+
105+
if (shockerIds is { Length: > 0 })
106+
query = query.Where(x => ((IEnumerable<Guid>)shockerIds).Contains(x.ShockerId));
107+
108+
var ordered = query
109+
.ApplySort(pagination, AllLogsSorters, AllLogsDefaultSort)
110+
.ThenBy(x => x.Id);
111+
112+
return await ordered.ToPagedResultAsync(
113+
x => new LogEntryWithHub
92114
{
93115
Id = x.Id,
94116
HubId = x.Shocker.Device.Id,
@@ -114,12 +136,8 @@ public async Task<IActionResult> GetAllShockerLogs([FromQuery(Name = "offset")]
114136
Image = x.ControlledByUser.GetImageUrl(),
115137
CustomName = x.CustomName
116138
}
117-
})
118-
.ToListAsync();
119-
120-
return Ok(new ShockerLogsResponse
121-
{
122-
Logs = logs
123-
});
139+
},
140+
pagination,
141+
cancellationToken);
124142
}
125143
}

API/Models/Response/LogEntryWithHub.cs

Lines changed: 0 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,6 @@
22

33
namespace OpenShock.API.Models.Response;
44

5-
public sealed class ShockerLogsResponse
6-
{
7-
public required ICollection<LogEntryWithHub> Logs { get; init; }
8-
}
9-
105
public sealed class LogEntryWithHub
116
{
127
public required Guid Id { get; init; }
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
namespace OpenShock.Common.Utils.Pagination;
2+
3+
/// <summary>
4+
/// Page of results for a paginated list endpoint.
5+
/// </summary>
6+
/// <typeparam name="T">Item shape</typeparam>
7+
public sealed class PagedResult<T>
8+
{
9+
/// <summary>
10+
/// Items on the current page.
11+
/// </summary>
12+
public required IReadOnlyList<T> Items { get; set; }
13+
14+
/// <summary>
15+
/// 1-based current page index.
16+
/// </summary>
17+
public required uint Page { get; set; }
18+
19+
/// <summary>
20+
/// Page size used to produce this result.
21+
/// </summary>
22+
public required uint PageSize { get; set; }
23+
24+
/// <summary>
25+
/// Total number of items across all pages matching the query.
26+
/// </summary>
27+
public required int TotalCount { get; set; }
28+
29+
/// <summary>
30+
/// Total number of pages. Always at least 1, even when <see cref="TotalCount"/> is zero.
31+
/// </summary>
32+
public uint TotalPages => TotalCount <= 0
33+
? 1u
34+
: (uint)Math.Ceiling(TotalCount / (double)PageSize);
35+
}
Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
using Microsoft.AspNetCore.Mvc;
2+
3+
namespace OpenShock.Common.Utils.Pagination;
4+
5+
/// <summary>
6+
/// Common query parameters for paginated list endpoints. Bind with
7+
/// <c>[FromQuery] PaginationQuery query</c> on the action.
8+
/// </summary>
9+
public sealed class PaginationQuery
10+
{
11+
/// <summary>
12+
/// Default page size when the client does not specify one.
13+
/// </summary>
14+
public const uint DefaultPageSize = 25;
15+
16+
/// <summary>
17+
/// Upper bound for <see cref="PageSize"/> regardless of what the client sends.
18+
/// </summary>
19+
public const uint MaxPageSize = 200;
20+
21+
private uint _page = 1;
22+
private uint _pageSize = DefaultPageSize;
23+
24+
/// <summary>
25+
/// 1-based page index. Values below 1 are clamped to 1.
26+
/// </summary>
27+
[FromQuery(Name = "page")]
28+
public uint Page
29+
{
30+
get => _page;
31+
set => _page = value < 1 ? 1 : value;
32+
}
33+
34+
/// <summary>
35+
/// Number of items per page. Clamped to <c>[1, <see cref="MaxPageSize"/>]</c>.
36+
/// </summary>
37+
[FromQuery(Name = "pageSize")]
38+
public uint PageSize
39+
{
40+
get => _pageSize;
41+
set
42+
{
43+
if (value < 1) _pageSize = 1;
44+
else if (value > MaxPageSize) _pageSize = MaxPageSize;
45+
else _pageSize = value;
46+
}
47+
}
48+
49+
private string? _search;
50+
51+
/// <summary>
52+
/// Optional free-text search term. Whitespace is trimmed; empty becomes null.
53+
/// Endpoints decide which fields the term is matched against.
54+
/// </summary>
55+
[FromQuery(Name = "search")]
56+
public string? Search
57+
{
58+
get => _search;
59+
set => _search = string.IsNullOrWhiteSpace(value) ? null : value.Trim();
60+
}
61+
62+
private string? _sort;
63+
64+
/// <summary>
65+
/// Optional sort key. Endpoints register the allowed keys and translate them to
66+
/// the underlying column(s). Unknown values fall back to the endpoint default.
67+
/// </summary>
68+
[FromQuery(Name = "sort")]
69+
public string? Sort
70+
{
71+
get => _sort;
72+
set => _sort = string.IsNullOrWhiteSpace(value) ? null : value.Trim();
73+
}
74+
75+
/// <summary>
76+
/// Sort direction. Defaults to <see cref="SortDirection.Desc"/> when omitted, since list
77+
/// endpoints almost always want "newest/highest first" as the default.
78+
/// </summary>
79+
[FromQuery(Name = "sortDir")]
80+
public SortDirection SortDir { get; set; } = SortDirection.Desc;
81+
82+
/// <summary>
83+
/// Number of items to skip based on <see cref="Page"/> and <see cref="PageSize"/>.
84+
/// </summary>
85+
public uint GetSkip() => (Page - 1) * PageSize;
86+
}
87+
88+
/// <summary>
89+
/// Sort direction for list endpoints.
90+
/// </summary>
91+
public enum SortDirection
92+
{
93+
Asc,
94+
Desc,
95+
}
Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
using System.Linq.Expressions;
2+
using Microsoft.EntityFrameworkCore;
3+
4+
namespace OpenShock.Common.Utils.Pagination;
5+
6+
/// <summary>
7+
/// Applies an ordering to <paramref name="source"/>. <paramref name="descending"/> indicates
8+
/// whether the caller-requested direction is descending; the implementation owns the
9+
/// actual column(s) and is responsible for honouring that.
10+
/// </summary>
11+
public delegate IOrderedQueryable<T> SortFunc<T>(IQueryable<T> source, bool descending);
12+
13+
/// <summary>
14+
/// Reusable helpers for applying <see cref="PaginationQuery"/> to an <see cref="IQueryable{T}"/>.
15+
/// </summary>
16+
public static class QueryableExtensions
17+
{
18+
/// <summary>
19+
/// Applies the sort requested by <paramref name="pagination"/> using the supplied
20+
/// <paramref name="sorters"/> map. Falls back to <paramref name="defaultSort"/> when
21+
/// the client did not specify a sort key or sent an unknown one.
22+
///
23+
/// Callers should chain a stable tiebreaker (e.g. <c>.ThenBy(x =&gt; x.Id)</c>) after
24+
/// this call so pages remain deterministic when the primary sort has ties.
25+
/// </summary>
26+
public static IOrderedQueryable<T> ApplySort<T>(
27+
this IQueryable<T> source,
28+
PaginationQuery pagination,
29+
IReadOnlyDictionary<string, SortFunc<T>> sorters,
30+
string defaultSort)
31+
{
32+
var key = pagination.Sort is { } requested && sorters.ContainsKey(requested)
33+
? requested
34+
: defaultSort;
35+
var descending = pagination.SortDir == SortDirection.Desc;
36+
return sorters[key](source, descending);
37+
}
38+
39+
/// <summary>
40+
/// Executes <paramref name="query"/> as a paginated request: runs one COUNT and one
41+
/// SELECT with <c>Skip</c>/<c>Take</c> based on <paramref name="pagination"/>.
42+
///
43+
/// Apply search filters and ordering on <paramref name="query"/> before calling this —
44+
/// pagination without a stable order produces non-deterministic pages.
45+
/// </summary>
46+
public static async Task<PagedResult<T>> ToPagedResultAsync<T>(
47+
this IQueryable<T> query,
48+
PaginationQuery pagination,
49+
CancellationToken cancellationToken)
50+
{
51+
var totalCount = await query.CountAsync(cancellationToken);
52+
53+
var items = await query
54+
.Skip((int)pagination.GetSkip())
55+
.Take((int)pagination.PageSize)
56+
.ToArrayAsync(cancellationToken);
57+
58+
return new PagedResult<T>
59+
{
60+
Items = items,
61+
Page = pagination.Page,
62+
PageSize = pagination.PageSize,
63+
TotalCount = totalCount
64+
};
65+
}
66+
67+
/// <summary>
68+
/// Variant of <see cref="ToPagedResultAsync{T}"/> that counts on the source query and
69+
/// applies <paramref name="projection"/> only to the page slice. Useful when the
70+
/// projection is expensive or pulls in joins that the count does not need.
71+
/// </summary>
72+
public static async Task<PagedResult<TResult>> ToPagedResultAsync<TSource, TResult>(
73+
this IQueryable<TSource> query,
74+
Expression<Func<TSource, TResult>> projection,
75+
PaginationQuery pagination,
76+
CancellationToken cancellationToken)
77+
{
78+
var totalCount = await query.CountAsync(cancellationToken);
79+
80+
var items = await query
81+
.Skip((int)pagination.GetSkip())
82+
.Take((int)pagination.PageSize)
83+
.Select(projection)
84+
.ToArrayAsync(cancellationToken);
85+
86+
return new PagedResult<TResult>
87+
{
88+
Items = items,
89+
Page = pagination.Page,
90+
PageSize = pagination.PageSize,
91+
TotalCount = totalCount
92+
};
93+
}
94+
}

0 commit comments

Comments
 (0)