Skip to content

Commit 45bce47

Browse files
LucHeartclaude
andcommitted
feat: bulk control stat changes endpoint for integrations
POST /1/integrations/control-stats/changes returns the lifetime totals of the requested users whose stats changed since a cursor, so a service like the Discord bot can keep many users in sync with one request per tick instead of polling each user. Gated by the new ControlStatsRead automation token type. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1 parent c13e697 commit 45bce47

10 files changed

Lines changed: 2286 additions & 3 deletions
Lines changed: 159 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,159 @@
1+
using System.Net;
2+
using System.Text.Json;
3+
using Microsoft.Extensions.DependencyInjection;
4+
using OpenShock.API.IntegrationTests.Helpers;
5+
using OpenShock.Common.Constants;
6+
using OpenShock.Common.OpenShockDb;
7+
using OpenShock.Common.Services.AutomationTokens;
8+
using OpenShock.Common.Utils;
9+
10+
namespace OpenShock.API.IntegrationTests.Tests;
11+
12+
public sealed class ControlStatChangesTests
13+
{
14+
private const string Password = "SecurePassword123#";
15+
private const string Endpoint = "/1/integrations/control-stats/changes";
16+
17+
[ClassDataSource<WebApplicationFactory>(Shared = SharedType.PerTestSession)]
18+
public required WebApplicationFactory WebApplicationFactory { get; init; }
19+
20+
[Test]
21+
public async Task WithoutControlStatsReadToken_Returns403()
22+
{
23+
var body = TestHelper.JsonContent(new { userIds = new[] { Guid.NewGuid() } });
24+
25+
using var anonymous = WebApplicationFactory.CreateClient();
26+
await Assert.That((await anonymous.PostAsync(Endpoint, body)).StatusCode).IsEqualTo(HttpStatusCode.Forbidden);
27+
28+
using var wrongType = await CreateTokenClient([AutomationTokenType.Turnstile, AutomationTokenType.RateLimit]);
29+
await Assert.That((await wrongType.PostAsync(Endpoint, body)).StatusCode).IsEqualTo(HttpStatusCode.Forbidden);
30+
}
31+
32+
[Test]
33+
public async Task InitialSync_ReportsEveryRequestedUser_WithReceivedAndGivenTotals()
34+
{
35+
var owner = await CreateUser("chgowner");
36+
var controller = await CreateUser("chgctrl");
37+
var idle = await CreateUser("chgidle");
38+
var notRequested = await CreateUser("chgother");
39+
var shockerId = await CreateShocker(owner, "Changes");
40+
var at = new DateTime(2026, 3, 1, 12, 0, 0, DateTimeKind.Utc);
41+
42+
await Upsert(
43+
Delta(shockerId, controller, at, count: 2, intensity: 40),
44+
Delta(shockerId, notRequested, at, count: 7));
45+
46+
using var client = await CreateTokenClient([AutomationTokenType.ControlStatsRead]);
47+
using var doc = await Post(client, new { userIds = new[] { owner, controller, idle } });
48+
49+
var users = doc.RootElement.GetProperty("users").EnumerateArray()
50+
.ToDictionary(x => x.GetProperty("userId").GetGuid());
51+
await Assert.That(users.Keys).IsEquivalentTo(new[] { owner, controller, idle });
52+
53+
// Lifetime totals, so the not requested controller's activity counts towards the owner as well
54+
var received = SingleShock(users[owner].GetProperty("received"));
55+
await Assert.That(received.GetProperty("count").GetInt64()).IsEqualTo(9);
56+
await Assert.That(users[owner].GetProperty("given").GetArrayLength()).IsEqualTo(0);
57+
58+
var given = SingleShock(users[controller].GetProperty("given"));
59+
await Assert.That(given.GetProperty("count").GetInt64()).IsEqualTo(2);
60+
await Assert.That(given.GetProperty("averageIntensity").GetDouble()).IsEqualTo(40).Within(0.001);
61+
62+
await Assert.That(users[idle].GetProperty("received").GetArrayLength()).IsEqualTo(0);
63+
await Assert.That(users[idle].GetProperty("given").GetArrayLength()).IsEqualTo(0);
64+
}
65+
66+
[Test]
67+
public async Task Since_OnlyReportsUsersWithRecentActivity()
68+
{
69+
var owner = await CreateUser("chgsince");
70+
var quiet = await CreateUser("chgquiet");
71+
var shockerId = await CreateShocker(owner, "Since");
72+
var quietShockerId = await CreateShocker(quiet, "Quiet");
73+
74+
// Old activity only, outside of any lookback
75+
var old = new DateTime(2026, 1, 1, 12, 0, 0, DateTimeKind.Utc);
76+
await Upsert(Delta(shockerId, owner, old, count: 3), Delta(quietShockerId, quiet, old, count: 1));
77+
78+
using var client = await CreateTokenClient([AutomationTokenType.ControlStatsRead]);
79+
var userIds = new[] { owner, quiet };
80+
81+
DateTime cursor;
82+
using (var initial = await Post(client, new { userIds }))
83+
cursor = initial.RootElement.GetProperty("cursor").GetDateTime();
84+
85+
using (var unchanged = await Post(client, new { since = cursor, userIds }))
86+
await Assert.That(unchanged.RootElement.GetProperty("users").GetArrayLength()).IsEqualTo(0);
87+
88+
await Upsert(Delta(shockerId, owner, DateTime.UtcNow, count: 1));
89+
90+
using var changed = await Post(client, new { since = cursor, userIds });
91+
var user = changed.RootElement.GetProperty("users").EnumerateArray().Single();
92+
await Assert.That(user.GetProperty("userId").GetGuid()).IsEqualTo(owner);
93+
await Assert.That(SingleShock(user.GetProperty("received")).GetProperty("count").GetInt64()).IsEqualTo(4);
94+
await Assert.That(SingleShock(user.GetProperty("given")).GetProperty("count").GetInt64()).IsEqualTo(4);
95+
}
96+
97+
private static ControlStatDelta Delta(Guid shockerId, Guid? controllerId, DateTime at, long count, byte intensity = 50) =>
98+
new(shockerId, controllerId, at, ControlType.Shock, Live: false, count, DurationMs: count * 1000,
99+
IntensityMs: count * 1000 * intensity, intensity);
100+
101+
private static JsonElement SingleShock(JsonElement totals) => totals.EnumerateArray()
102+
.Single(x => x.GetProperty("type").GetString() == "Shock" && !x.GetProperty("live").GetBoolean());
103+
104+
private async Task Upsert(params ControlStatDelta[] deltas)
105+
{
106+
await using var scope = WebApplicationFactory.Services.CreateAsyncScope();
107+
await scope.ServiceProvider.GetRequiredService<OpenShockContext>().UpsertControlStatsAsync(deltas);
108+
}
109+
110+
private static async Task<JsonDocument> Post(HttpClient client, object body)
111+
{
112+
var response = await client.PostAsync(Endpoint, TestHelper.JsonContent(body));
113+
await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.OK);
114+
return JsonDocument.Parse(await response.Content.ReadAsStringAsync());
115+
}
116+
117+
private Task<Guid> CreateUser(string prefix) => TestHelper.CreateUserInDb(WebApplicationFactory,
118+
TestHelper.UniqueUsername(prefix), TestHelper.UniqueEmail(prefix), Password);
119+
120+
private async Task<HttpClient> CreateTokenClient(List<AutomationTokenType> types)
121+
{
122+
await using var scope = WebApplicationFactory.Services.CreateAsyncScope();
123+
var db = scope.ServiceProvider.GetRequiredService<OpenShockContext>();
124+
125+
var secret = IAutomationTokenService.GenerateSecret();
126+
db.AutomationTokens.Add(new AutomationToken
127+
{
128+
Id = Guid.CreateVersion7(),
129+
Name = "control-stats-test",
130+
TokenHash = HashingUtils.HashToken(secret),
131+
Types = types
132+
});
133+
await db.SaveChangesAsync();
134+
135+
var client = WebApplicationFactory.CreateClient();
136+
client.DefaultRequestHeaders.Add(AuthConstants.AutomationTokenHeaderName, secret);
137+
return client;
138+
}
139+
140+
private async Task<Guid> CreateShocker(Guid ownerId, string name)
141+
{
142+
var (deviceId, _) = await TestHelper.CreateDeviceInDb(WebApplicationFactory, ownerId);
143+
144+
await using var scope = WebApplicationFactory.Services.CreateAsyncScope();
145+
var db = scope.ServiceProvider.GetRequiredService<OpenShockContext>();
146+
147+
var shockerId = Guid.CreateVersion7();
148+
db.Shockers.Add(new Shocker
149+
{
150+
Id = shockerId,
151+
Name = name,
152+
Model = ShockerModelType.CaiXianlin,
153+
RfId = 1234,
154+
DeviceId = deviceId
155+
});
156+
await db.SaveChangesAsync();
157+
return shockerId;
158+
}
159+
}
Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
using System.Net.Mime;
2+
using Microsoft.AspNetCore.Mvc;
3+
using Microsoft.EntityFrameworkCore;
4+
using OpenShock.API.Errors;
5+
using OpenShock.API.Models.Requests;
6+
using OpenShock.API.Models.Response;
7+
using OpenShock.Common.Extensions;
8+
using OpenShock.Common.Models;
9+
using OpenShock.Common.OpenShockDb;
10+
using OpenShock.Common.Problems;
11+
12+
using OpenShock.Internal.Common.Problems;
13+
14+
namespace OpenShock.API.Controller.Integrations;
15+
16+
public sealed partial class IntegrationsController
17+
{
18+
/// <summary>
19+
/// Lets a service holding many users keep their lifetime control stats in sync with a single call per tick.
20+
/// Returns the lifetime totals of every requested user whose stats changed since the given cursor.
21+
/// </summary>
22+
/// <remarks>
23+
/// Requires an automation token granting <c>ControlStatsRead</c>. Stats rows are hourly buckets that only
24+
/// ever change while their hour is current, so "changed since" is answered from the bucket indexes. The
25+
/// lookback is widened by <see cref="FlushOverlap"/> to cover buffered live control stats flushed just after
26+
/// the hour rolled over, so a user can be reported again although nothing changed.
27+
/// </remarks>
28+
/// <response code="200">The changed users' totals and the cursor to pass next time.</response>
29+
/// <response code="403">No automation token granting ControlStatsRead was presented.</response>
30+
[HttpPost("control-stats/changes")]
31+
[ProducesResponseType<ControlStatChangesResponse>(StatusCodes.Status200OK, MediaTypeNames.Application.Json)]
32+
[ProducesResponseType<OpenShockProblem>(StatusCodes.Status403Forbidden, MediaTypeNames.Application.ProblemJson)] // MissingCapability
33+
public async Task<IActionResult> GetControlStatChanges(ControlStatChangesRequest body, CancellationToken cancellationToken)
34+
{
35+
if (!HttpContext.IsBypassed(AutomationTokenType.ControlStatsRead))
36+
return Problem(ControlStatsError.MissingCapability);
37+
38+
// Taken before querying, so anything written while we read is picked up by the next call
39+
var cursor = DateTime.UtcNow;
40+
var userIds = body.UserIds.Distinct().ToArray();
41+
42+
Guid[] changed;
43+
if (body.Since is { } since)
44+
{
45+
var from = ControlStatDelta.ToBucket(since.ToUniversalTime() - FlushOverlap);
46+
var recent = _db.ControlStats.Where(x => x.Bucket >= from);
47+
48+
var owners = await recent
49+
.Where(x => userIds.Contains(x.OwnerUserId))
50+
.Select(x => x.OwnerUserId)
51+
.Distinct()
52+
.ToArrayAsync(cancellationToken);
53+
var controllers = await recent
54+
.Where(x => x.ControllerUserId != null && userIds.Contains(x.ControllerUserId.Value))
55+
.Select(x => x.ControllerUserId!.Value)
56+
.Distinct()
57+
.ToArrayAsync(cancellationToken);
58+
59+
changed = owners.Union(controllers).ToArray();
60+
}
61+
else
62+
{
63+
// Initial sync, everyone requested is reported, users without any stats with empty totals
64+
changed = userIds;
65+
}
66+
67+
if (changed.Length == 0)
68+
return Ok(new ControlStatChangesResponse { Cursor = cursor, Users = [] });
69+
70+
var received = await _db.ControlStats
71+
.Where(x => changed.Contains(x.OwnerUserId))
72+
.GroupBy(x => new { UserId = x.OwnerUserId, x.Type, x.Live })
73+
.Select(g => new TotalsRow(g.Key.UserId, g.Key.Type, g.Key.Live, g.Sum(x => x.Count),
74+
g.Sum(x => x.DurationMs), g.Sum(x => x.IntensityMs), g.Max(x => x.MaxIntensity)))
75+
.ToArrayAsync(cancellationToken);
76+
77+
var given = await _db.ControlStats
78+
.Where(x => x.ControllerUserId != null && changed.Contains(x.ControllerUserId.Value))
79+
.GroupBy(x => new { UserId = x.ControllerUserId!.Value, x.Type, x.Live })
80+
.Select(g => new TotalsRow(g.Key.UserId, g.Key.Type, g.Key.Live, g.Sum(x => x.Count),
81+
g.Sum(x => x.DurationMs), g.Sum(x => x.IntensityMs), g.Max(x => x.MaxIntensity)))
82+
.ToArrayAsync(cancellationToken);
83+
84+
var receivedByUser = received.ToLookup(x => x.UserId);
85+
var givenByUser = given.ToLookup(x => x.UserId);
86+
87+
return Ok(new ControlStatChangesResponse
88+
{
89+
Cursor = cursor,
90+
Users = changed
91+
.Select(id => new UserControlStatTotals
92+
{
93+
UserId = id,
94+
Received = ToTotals(receivedByUser[id]),
95+
Given = ToTotals(givenByUser[id])
96+
})
97+
.ToArray()
98+
});
99+
}
100+
101+
/// <summary>Well above the live control gateway's stats flush interval.</summary>
102+
private static readonly TimeSpan FlushOverlap = TimeSpan.FromMinutes(5);
103+
104+
private static ControlStatTotals[] ToTotals(IEnumerable<TotalsRow> rows) => rows
105+
.OrderBy(x => x.Type)
106+
.ThenBy(x => x.Live)
107+
.Select(x => new ControlStatTotals
108+
{
109+
Type = x.Type,
110+
Live = x.Live,
111+
Count = x.Count,
112+
DurationMs = x.DurationMs,
113+
AverageIntensity = x.DurationMs == 0 ? 0 : (double)x.IntensityMs / x.DurationMs,
114+
MaxIntensity = x.MaxIntensity
115+
})
116+
.ToArray();
117+
118+
private sealed record TotalsRow(
119+
Guid UserId,
120+
ControlType Type,
121+
bool Live,
122+
long Count,
123+
long DurationMs,
124+
long IntensityMs,
125+
byte MaxIntensity);
126+
}
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
using Microsoft.AspNetCore.Mvc;
2+
using OpenShock.Common;
3+
using OpenShock.Common.OpenShockDb;
4+
5+
namespace OpenShock.API.Controller.Integrations;
6+
7+
/// <summary>
8+
/// Endpoints for first party services (e.g. the Discord bot). Not tied to a user session, every action checks
9+
/// for an automation token that grants the capability it needs.
10+
/// </summary>
11+
[ApiController]
12+
[Tags("Integrations")]
13+
[Route("/{version:apiVersion}/integrations")]
14+
public sealed partial class IntegrationsController : OpenShockControllerBase
15+
{
16+
private readonly OpenShockContext _db;
17+
18+
public IntegrationsController(OpenShockContext db)
19+
{
20+
_db = db;
21+
}
22+
}

‎API/Errors/ControlStatsError.cs‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,5 +8,6 @@ namespace OpenShock.API.Errors;
88
public static class ControlStatsError
99
{
1010
public static OpenShockProblem InvalidTimeZone => new("ControlStats.InvalidTimeZone", "Unknown time zone, expected an IANA time zone id like Europe/Berlin", HttpStatusCode.BadRequest);
11+
public static OpenShockProblem MissingCapability => new("ControlStats.MissingCapability", "An automation token granting ControlStatsRead is required", HttpStatusCode.Forbidden);
1112
public static OpenShockProblem InvalidRange => new("ControlStats.InvalidRange", "The from date must not be after the to date", HttpStatusCode.BadRequest);
1213
}
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
using System.ComponentModel.DataAnnotations;
2+
3+
namespace OpenShock.API.Models.Requests;
4+
5+
public sealed class ControlStatChangesRequest
6+
{
7+
/// <summary>The cursor returned by the previous call, omit for an initial sync of every requested user.</summary>
8+
public DateTime? Since { get; init; }
9+
10+
/// <summary>The users the caller is interested in, only these are ever reported.</summary>
11+
[Required]
12+
[MaxLength(MaxUserIds)]
13+
public required Guid[] UserIds { get; init; }
14+
15+
public const int MaxUserIds = 1000;
16+
}

‎API/Models/Response/ControlStatsResponse.cs‎

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,3 +68,23 @@ public sealed class DailyControlStats
6868
public required DateOnly Date { get; init; }
6969
public required ControlStatTotals[] Stats { get; init; }
7070
}
71+
72+
public sealed class ControlStatChangesResponse
73+
{
74+
/// <summary>Pass as <c>since</c> on the next call.</summary>
75+
public required DateTime Cursor { get; init; }
76+
77+
public required UserControlStatTotals[] Users { get; init; }
78+
}
79+
80+
/// <summary>Lifetime totals of one user, the same views as <see cref="ControlStatsResponse"/> without breakdowns.</summary>
81+
public sealed class UserControlStatTotals
82+
{
83+
public required Guid UserId { get; init; }
84+
85+
/// <summary>Activity on shockers the user owns.</summary>
86+
public required ControlStatTotals[] Received { get; init; }
87+
88+
/// <summary>Activity the user sent to shockers, their own included.</summary>
89+
public required ControlStatTotals[] Given { get; init; }
90+
}

0 commit comments

Comments
 (0)