diff --git a/API.IntegrationTests/Tests/ControlStatChangesTests.cs b/API.IntegrationTests/Tests/ControlStatChangesTests.cs new file mode 100644 index 00000000..49ec6549 --- /dev/null +++ b/API.IntegrationTests/Tests/ControlStatChangesTests.cs @@ -0,0 +1,242 @@ +using System.Net; +using System.Text.Json; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using OpenShock.API.IntegrationTests.Helpers; +using OpenShock.Common.Constants; +using OpenShock.Common.OpenShockDb; +using OpenShock.Common.Services.AutomationTokens; +using OpenShock.Common.Utils; + +namespace OpenShock.API.IntegrationTests.Tests; + +public sealed class ControlStatChangesTests +{ + private const string Password = "SecurePassword123#"; + private const string Endpoint = "/1/integrations/control-stats/changes"; + + [ClassDataSource(Shared = SharedType.PerTestSession)] + public required WebApplicationFactory WebApplicationFactory { get; init; } + + [Test] + public async Task WithoutControlStatsReadToken_Returns403() + { + var body = TestHelper.JsonContent(new { userIds = new[] { Guid.NewGuid() } }); + + using var anonymous = WebApplicationFactory.CreateClient(); + await Assert.That((await anonymous.PostAsync(Endpoint, body)).StatusCode).IsEqualTo(HttpStatusCode.Forbidden); + + using var wrongType = await CreateTokenClient([AutomationTokenType.Turnstile, AutomationTokenType.RateLimit]); + await Assert.That((await wrongType.PostAsync(Endpoint, body)).StatusCode).IsEqualTo(HttpStatusCode.Forbidden); + } + + [Test] + public async Task InitialSync_ReportsEveryRequestedUser_WithReceivedAndGivenTotals() + { + var owner = await CreateUser("chgowner"); + var controller = await CreateUser("chgctrl"); + var idle = await CreateUser("chgidle"); + var notRequested = await CreateUser("chgother"); + var shockerId = await CreateShocker(owner, "Changes"); + var at = new DateTime(2026, 3, 1, 12, 0, 0, DateTimeKind.Utc); + + await Upsert( + Delta(owner, shockerId, controller, at, count: 2, intensity: 40), + Delta(owner, shockerId, notRequested, at, count: 7)); + + using var client = await CreateTokenClient([AutomationTokenType.ControlStatsRead]); + using var doc = await Post(client, new { userIds = new[] { owner, controller, idle } }); + + var users = doc.RootElement.GetProperty("users").EnumerateArray() + .ToDictionary(x => x.GetProperty("userId").GetGuid()); + await Assert.That(users.Keys).IsEquivalentTo(new[] { owner, controller, idle }); + + // Lifetime totals, so the not requested controller's activity counts towards the owner as well + var received = SingleShock(users[owner].GetProperty("received")); + await Assert.That(received.GetProperty("count").GetInt64()).IsEqualTo(9); + await Assert.That(users[owner].GetProperty("given").GetArrayLength()).IsEqualTo(0); + + var given = SingleShock(users[controller].GetProperty("given")); + await Assert.That(given.GetProperty("count").GetInt64()).IsEqualTo(2); + await Assert.That(given.GetProperty("averageIntensity").GetDouble()).IsEqualTo(40).Within(0.001); + + await Assert.That(users[idle].GetProperty("received").GetArrayLength()).IsEqualTo(0); + await Assert.That(users[idle].GetProperty("given").GetArrayLength()).IsEqualTo(0); + } + + [Test] + public async Task Since_OnlyReportsUsersWithRecentActivity() + { + var owner = await CreateUser("chgsince"); + var quiet = await CreateUser("chgquiet"); + var shockerId = await CreateShocker(owner, "Since"); + var quietShockerId = await CreateShocker(quiet, "Quiet"); + + // Old activity only, outside of any lookback + var old = new DateTime(2026, 1, 1, 12, 0, 0, DateTimeKind.Utc); + await Upsert(Delta(owner, shockerId, owner, old, count: 3), Delta(quiet, quietShockerId, quiet, old, count: 1)); + await Backdate(owner, quiet); + + using var client = await CreateTokenClient([AutomationTokenType.ControlStatsRead]); + var userIds = new[] { owner, quiet }; + + DateTime cursor; + using (var initial = await Post(client, new { userIds })) + cursor = initial.RootElement.GetProperty("cursor").GetDateTime(); + + using (var unchanged = await Post(client, new { since = cursor, userIds })) + await Assert.That(unchanged.RootElement.GetProperty("users").GetArrayLength()).IsEqualTo(0); + + await Upsert(Delta(owner, shockerId, owner, DateTime.UtcNow, count: 1)); + + using var changed = await Post(client, new { since = cursor, userIds }); + var user = changed.RootElement.GetProperty("users").EnumerateArray().Single(); + await Assert.That(user.GetProperty("userId").GetGuid()).IsEqualTo(owner); + await Assert.That(SingleShock(user.GetProperty("received")).GetProperty("count").GetInt64()).IsEqualTo(4); + await Assert.That(SingleShock(user.GetProperty("given")).GetProperty("count").GetInt64()).IsEqualTo(4); + } + + [Test] + public async Task Since_AtMinValue_ReportsUsersWithAnyActivity() + { + var owner = await CreateUser("chgmin"); + var idle = await CreateUser("chgminidle"); + var shockerId = await CreateShocker(owner, "MinValue"); + await Upsert(Delta(owner, shockerId, owner, new DateTime(2026, 1, 1, 12, 0, 0, DateTimeKind.Utc), count: 2)); + + using var client = await CreateTokenClient([AutomationTokenType.ControlStatsRead]); + + // With and without offset, the latter is read as UTC + foreach (var since in new[] { "0001-01-01T00:00:00Z", "0001-01-01T00:00:00" }) + { + using var doc = await Post(client, new { since, userIds = new[] { owner, idle } }); + + var user = doc.RootElement.GetProperty("users").EnumerateArray().Single(); + await Assert.That(user.GetProperty("userId").GetGuid()).IsEqualTo(owner); + await Assert.That(SingleShock(user.GetProperty("received")).GetProperty("count").GetInt64()).IsEqualTo(2); + } + } + + [Test] + public async Task Since_ReportsLateWritesIntoOldBuckets() + { + var owner = await CreateUser("chglate"); + var shockerId = await CreateShocker(owner, "Late"); + + using var client = await CreateTokenClient([AutomationTokenType.ControlStatsRead]); + var userIds = new[] { owner }; + + DateTime cursor; + using (var initial = await Post(client, new { userIds })) + cursor = initial.RootElement.GetProperty("cursor").GetDateTime(); + + // Live control from hours ago that only reached the database now, e.g. after an outage + await Upsert(Delta(owner, shockerId, owner, DateTime.UtcNow.AddHours(-3), count: 1)); + + using var changed = await Post(client, new { since = cursor, userIds }); + var user = changed.RootElement.GetProperty("users").EnumerateArray().Single(); + await Assert.That(user.GetProperty("userId").GetGuid()).IsEqualTo(owner); + } + + [Test] + public async Task Since_ReportsControllersWhoseStatsWereDeleted() + { + var owner = await CreateUser("chgdelown"); + var controller = await CreateUser("chgdelctl"); + var shockerId = await CreateShocker(owner, "Deleted"); + await Upsert(Delta(owner, shockerId, controller, new DateTime(2026, 1, 1, 12, 0, 0, DateTimeKind.Utc), count: 4)); + await Backdate(owner); + + using var client = await CreateTokenClient([AutomationTokenType.ControlStatsRead]); + var userIds = new[] { controller }; + + DateTime cursor; + using (var initial = await Post(client, new { userIds })) + cursor = initial.RootElement.GetProperty("cursor").GetDateTime(); + + // As when the owner's account is deleted, which cascades onto their stats + await using (var scope = WebApplicationFactory.Services.CreateAsyncScope()) + { + var db = scope.ServiceProvider.GetRequiredService(); + await db.ControlStats.Where(x => x.OwnerUserId == owner).ExecuteDeleteAsync(); + } + + using var changed = await Post(client, new { since = cursor, userIds }); + var user = changed.RootElement.GetProperty("users").EnumerateArray().Single(); + await Assert.That(user.GetProperty("userId").GetGuid()).IsEqualTo(controller); + await Assert.That(user.GetProperty("given").GetArrayLength()).IsEqualTo(0); + } + + /// Moves the users' rows out of the change lookback, as if written long ago. + private async Task Backdate(params Guid[] ownerIds) + { + await using var scope = WebApplicationFactory.Services.CreateAsyncScope(); + var db = scope.ServiceProvider.GetRequiredService(); + var longAgo = DateTime.UtcNow.AddDays(-1); + await db.ControlStats.Where(x => ownerIds.Contains(x.OwnerUserId)) + .ExecuteUpdateAsync(x => x.SetProperty(y => y.UpdatedAt, longAgo)); + } + + private static ControlStatDelta Delta(Guid ownerId, Guid shockerId, Guid? controllerId, DateTime at, long count, byte intensity = 50) => + new(ownerId, shockerId, controllerId, at, ControlType.Shock, Live: false, count, DurationMs: count * 1000, + IntensityMs: count * 1000 * intensity, intensity); + + private static JsonElement SingleShock(JsonElement totals) => totals.EnumerateArray() + .Single(x => x.GetProperty("type").GetString() == "Shock" && !x.GetProperty("live").GetBoolean()); + + private async Task Upsert(params ControlStatDelta[] deltas) + { + await using var scope = WebApplicationFactory.Services.CreateAsyncScope(); + await scope.ServiceProvider.GetRequiredService().UpsertControlStatsAsync(deltas); + } + + private static async Task Post(HttpClient client, object body) + { + var response = await client.PostAsync(Endpoint, TestHelper.JsonContent(body)); + await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.OK); + return JsonDocument.Parse(await response.Content.ReadAsStringAsync()); + } + + private Task CreateUser(string prefix) => TestHelper.CreateUserInDb(WebApplicationFactory, + TestHelper.UniqueUsername(prefix), TestHelper.UniqueEmail(prefix), Password); + + private async Task CreateTokenClient(List types) + { + await using var scope = WebApplicationFactory.Services.CreateAsyncScope(); + var db = scope.ServiceProvider.GetRequiredService(); + + var secret = IAutomationTokenService.GenerateSecret(); + db.AutomationTokens.Add(new AutomationToken + { + Id = Guid.CreateVersion7(), + Name = "control-stats-test", + TokenHash = HashingUtils.HashToken(secret), + Types = types + }); + await db.SaveChangesAsync(); + + var client = WebApplicationFactory.CreateClient(); + client.DefaultRequestHeaders.Add(AuthConstants.AutomationTokenHeaderName, secret); + return client; + } + + private async Task CreateShocker(Guid ownerId, string name) + { + var (deviceId, _) = await TestHelper.CreateDeviceInDb(WebApplicationFactory, ownerId); + + await using var scope = WebApplicationFactory.Services.CreateAsyncScope(); + var db = scope.ServiceProvider.GetRequiredService(); + + var shockerId = Guid.CreateVersion7(); + db.Shockers.Add(new Shocker + { + Id = shockerId, + Name = name, + Model = ShockerModelType.CaiXianlin, + RfId = 1234, + DeviceId = deviceId + }); + await db.SaveChangesAsync(); + return shockerId; + } +} diff --git a/API.IntegrationTests/Tests/ControlStatsTests.cs b/API.IntegrationTests/Tests/ControlStatsTests.cs new file mode 100644 index 00000000..620925ba --- /dev/null +++ b/API.IntegrationTests/Tests/ControlStatsTests.cs @@ -0,0 +1,312 @@ +using System.Net; +using System.Text.Json; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using OpenShock.API.IntegrationTests.Helpers; +using OpenShock.Common.OpenShockDb; + +namespace OpenShock.API.IntegrationTests.Tests; + +public sealed class ControlStatsTests +{ + [ClassDataSource(Shared = SharedType.PerTestSession)] + public required WebApplicationFactory WebApplicationFactory { get; init; } + + [Test] + public async Task SendControl_RecordsStats_ThatSurviveLogDeletion() + { + var user = await TestHelper.CreateAndLoginUser(WebApplicationFactory, TestHelper.UniqueUsername("stats"), + TestHelper.UniqueEmail("stats"), "SecurePassword123#"); + var shockerId = await CreateShocker(user.Id, "Stats Shocker"); + using var client = TestHelper.CreateAuthenticatedClient(WebApplicationFactory, user.SessionToken); + + foreach (var (type, intensity, duration) in new[] { ("Shock", 50, 1000), ("Shock", 100, 2000), ("Vibrate", 30, 500), ("Stop", 1, 300) }) + { + var response = await client.PostAsync("/2/shockers/control", TestHelper.JsonContent(new + { + shocks = new[] { new { id = shockerId, type, intensity, duration } } + })); + await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.OK); + } + + // The rollup must not depend on the logs, which get trimmed + await using (var scope = WebApplicationFactory.Services.CreateAsyncScope()) + { + var db = scope.ServiceProvider.GetRequiredService(); + await db.ShockerControlLogs.Where(x => x.ShockerId == shockerId).ExecuteDeleteAsync(); + } + + using var doc = await GetStats(client); + var received = doc.RootElement.GetProperty("received"); + + var totals = received.GetProperty("totals"); + await Assert.That(totals.GetArrayLength()).IsEqualTo(2); // Stop is not counted + + var shock = FindTotals(totals, "Shock", live: false); + await Assert.That(shock.GetProperty("count").GetInt64()).IsEqualTo(2); + await Assert.That(shock.GetProperty("durationMs").GetInt64()).IsEqualTo(3000); + await Assert.That(shock.GetProperty("averageIntensity").GetDouble()).IsEqualTo((50.0 * 1000 + 100.0 * 2000) / 3000).Within(0.001); + await Assert.That(shock.GetProperty("maxIntensity").GetInt32()).IsEqualTo(100); + + var vibrate = FindTotals(totals, "Vibrate", live: false); + await Assert.That(vibrate.GetProperty("count").GetInt64()).IsEqualTo(1); + + var shocker = received.GetProperty("shockers").EnumerateArray().Single(); + await Assert.That(shocker.GetProperty("shockerId").GetGuid()).IsEqualTo(shockerId); + await Assert.That(shocker.GetProperty("shockerName").GetString()).IsEqualTo("Stats Shocker"); + + var controller = received.GetProperty("controllers").EnumerateArray().Single(); + await Assert.That(controller.GetProperty("userId").GetGuid()).IsEqualTo(user.Id); + await Assert.That(controller.GetProperty("user").GetProperty("name").GetString()).IsEqualTo(user.Username); + + await Assert.That(received.GetProperty("days").GetArrayLength()).IsEqualTo(1); + + // Controlling your own shocker counts as given too + var given = doc.RootElement.GetProperty("given"); + await Assert.That(FindTotals(given.GetProperty("totals"), "Shock", live: false).GetProperty("count").GetInt64()).IsEqualTo(2); + await Assert.That(given.GetProperty("owners").EnumerateArray().Single().GetProperty("userId").GetGuid()).IsEqualTo(user.Id); + } + + [Test] + public async Task DeletingShockers_KeepsTheirStats_WithoutUniqueConflicts() + { + var user = await TestHelper.CreateAndLoginUser(WebApplicationFactory, TestHelper.UniqueUsername("statsdel"), + TestHelper.UniqueEmail("statsdel"), "SecurePassword123#"); + var shockerA = await CreateShocker(user.Id, "A"); + var shockerB = await CreateShocker(user.Id, "B"); + var bucket = new DateTime(2026, 3, 1, 12, 15, 0, DateTimeKind.Utc); + + await using (var scope = WebApplicationFactory.Services.CreateAsyncScope()) + { + var db = scope.ServiceProvider.GetRequiredService(); + + // Identical keys apart from the shocker, so after SET NULL they would collide on a full unique index + await db.UpsertControlStatsAsync([ + Delta(user.Id, shockerA, user.Id, bucket, count: 2), + Delta(user.Id, shockerB, user.Id, bucket, count: 3) + ]); + + await db.Shockers.Where(x => x.Id == shockerA).ExecuteDeleteAsync(); + await db.Shockers.Where(x => x.Id == shockerB).ExecuteDeleteAsync(); + + await Assert.That(await db.ControlStats.CountAsync(x => x.OwnerUserId == user.Id && x.ShockerId == null)).IsEqualTo(2); + } + + using var client = TestHelper.CreateAuthenticatedClient(WebApplicationFactory, user.SessionToken); + using var doc = await GetStats(client); + + var deleted = doc.RootElement.GetProperty("received").GetProperty("shockers").EnumerateArray().Single(); + await Assert.That(deleted.GetProperty("shockerId").ValueKind).IsEqualTo(JsonValueKind.Null); + await Assert.That(FindTotals(deleted.GetProperty("stats"), "Shock", live: false).GetProperty("count").GetInt64()).IsEqualTo(5); + } + + [Test] + public async Task Upsert_MergesDuplicateKeysAndAccumulates() + { + var userId = await TestHelper.CreateUserInDb(WebApplicationFactory, TestHelper.UniqueUsername("statsmerge"), + TestHelper.UniqueEmail("statsmerge"), "SecurePassword123#"); + var shockerId = await CreateShocker(userId, "Merge"); + var bucket = new DateTime(2026, 3, 1, 12, 0, 0, DateTimeKind.Utc); + + await using var scope = WebApplicationFactory.Services.CreateAsyncScope(); + var db = scope.ServiceProvider.GetRequiredService(); + + // Same bucket, same key twice in one batch, plus a guest (null controller) bucket + await db.UpsertControlStatsAsync([ + Delta(userId, shockerId, userId, bucket.AddMinutes(5), count: 1, intensity: 20), + Delta(userId, shockerId, userId, bucket.AddMinutes(14), count: 1, intensity: 80), + Delta(userId, shockerId, null, bucket, count: 4) + ]); + await db.UpsertControlStatsAsync([Delta(userId, shockerId, userId, bucket, count: 1, intensity: 10)]); + await db.UpsertControlStatsAsync([Delta(userId, shockerId, null, bucket, count: 1)]); + + var rows = await db.ControlStats.AsNoTracking().Where(x => x.ShockerId == shockerId).ToArrayAsync(); + await Assert.That(rows.Length).IsEqualTo(2); + + var own = rows.Single(x => x.ControllerUserId == userId); + await Assert.That(own.OwnerUserId).IsEqualTo(userId); + await Assert.That(own.Bucket).IsEqualTo(bucket); + await Assert.That(own.Count).IsEqualTo(3); + await Assert.That(own.DurationMs).IsEqualTo(3000); + await Assert.That(own.MaxIntensity).IsEqualTo((byte)80); + + await Assert.That(rows.Single(x => x.ControllerUserId == null).Count).IsEqualTo(5); + } + + [Test] + public async Task Upsert_ForShockerDeletedBeforeFlush_KeepsDeltaWithoutShocker() + { + var userId = await TestHelper.CreateUserInDb(WebApplicationFactory, TestHelper.UniqueUsername("statsgone"), + TestHelper.UniqueEmail("statsgone"), "SecurePassword123#"); + var shockerId = await CreateShocker(userId, "Gone"); + + await using var scope = WebApplicationFactory.Services.CreateAsyncScope(); + var db = scope.ServiceProvider.GetRequiredService(); + + // Buffered live control activity whose shocker got deleted before the flush + await db.Shockers.Where(x => x.Id == shockerId).ExecuteDeleteAsync(); + await db.UpsertControlStatsAsync([ + Delta(userId, shockerId, userId, new DateTime(2026, 3, 1, 12, 0, 0, DateTimeKind.Utc), count: 3) + ]); + + var row = await db.ControlStats.AsNoTracking().SingleAsync(x => x.OwnerUserId == userId); + await Assert.That(row.ShockerId).IsNull(); + await Assert.That(row.Count).IsEqualTo(3); + } + + [Test] + public async Task ApplyBatch_RetriedWithSameId_IsCountedOnce() + { + var userId = await TestHelper.CreateUserInDb(WebApplicationFactory, TestHelper.UniqueUsername("statsbatch"), + TestHelper.UniqueEmail("statsbatch"), "SecurePassword123#"); + var shockerId = await CreateShocker(userId, "Batch"); + var batchId = Guid.CreateVersion7(); + ControlStatDelta[] deltas = [Delta(userId, shockerId, userId, new DateTime(2026, 3, 1, 12, 0, 0, DateTimeKind.Utc), count: 2)]; + + await using var scope = WebApplicationFactory.Services.CreateAsyncScope(); + var db = scope.ServiceProvider.GetRequiredService(); + + var createdAt = DateTime.UtcNow; + await Assert.That(await db.ApplyControlStatBatchAsync(batchId, createdAt, deltas)).IsEqualTo(ControlStatBatchResult.Applied); + // Retry after a commit whose acknowledgement got lost + await Assert.That(await db.ApplyControlStatBatchAsync(batchId, createdAt, deltas)).IsEqualTo(ControlStatBatchResult.AlreadyApplied); + + var row = await db.ControlStats.AsNoTracking().SingleAsync(x => x.ShockerId == shockerId); + await Assert.That(row.Count).IsEqualTo(2); + } + + [Test] + public async Task ApplyBatch_OlderThanReplayLifetime_IsRefused() + { + var userId = await TestHelper.CreateUserInDb(WebApplicationFactory, TestHelper.UniqueUsername("statsexp"), + TestHelper.UniqueEmail("statsexp"), "SecurePassword123#"); + var shockerId = await CreateShocker(userId, "Expired"); + var createdAt = DateTime.UtcNow - ControlStatQueries.BatchReplayLifetime - TimeSpan.FromMinutes(1); + + await using var scope = WebApplicationFactory.Services.CreateAsyncScope(); + var db = scope.ServiceProvider.GetRequiredService(); + + // Its marker may already be pruned, so applying it could count it a second time + var result = await db.ApplyControlStatBatchAsync(Guid.CreateVersion7(), createdAt, + [Delta(userId, shockerId, userId, createdAt, count: 1)]); + + await Assert.That(result).IsEqualTo(ControlStatBatchResult.Expired); + await Assert.That(await db.ControlStats.AnyAsync(x => x.ShockerId == shockerId)).IsFalse(); + } + + [Test] + public async Task Stats_GroupsDaysInRequestedTimeZone_AndFiltersRange() + { + var user = await TestHelper.CreateAndLoginUser(WebApplicationFactory, TestHelper.UniqueUsername("statstz"), + TestHelper.UniqueEmail("statstz"), "SecurePassword123#"); + var shockerId = await CreateShocker(user.Id, "Tz"); + + await using (var scope = WebApplicationFactory.Services.CreateAsyncScope()) + { + var db = scope.ServiceProvider.GetRequiredService(); + await db.UpsertControlStatsAsync([ + // 23:00 UTC on Jan 1 is already Jan 2 in Berlin (UTC+1) + Delta(user.Id, shockerId, user.Id, new DateTime(2026, 1, 1, 23, 0, 0, DateTimeKind.Utc), count: 1), + Delta(user.Id, shockerId, user.Id, new DateTime(2026, 1, 5, 12, 0, 0, DateTimeKind.Utc), count: 1) + ]); + } + + using var client = TestHelper.CreateAuthenticatedClient(WebApplicationFactory, user.SessionToken); + + using (var utc = await GetStats(client)) + await Assert.That(Days(utc)).IsEquivalentTo(new[] { "2026-01-01", "2026-01-05" }); + + using (var berlin = await GetStats(client, "?tz=Europe/Berlin")) + await Assert.That(Days(berlin)).IsEquivalentTo(new[] { "2026-01-02", "2026-01-05" }); + + using (var ranged = await GetStats(client, "?tz=Europe/Berlin&from=2026-01-02&to=2026-01-02")) + await Assert.That(Days(ranged)).IsEquivalentTo(new[] { "2026-01-02" }); + } + + [Test] + public async Task Stats_HalfHourOffsetTimeZone_SplitsDaysAtLocalMidnight() + { + var user = await TestHelper.CreateAndLoginUser(WebApplicationFactory, TestHelper.UniqueUsername("statskol"), + TestHelper.UniqueEmail("statskol"), "SecurePassword123#"); + var shockerId = await CreateShocker(user.Id, "Kolkata"); + + await using (var scope = WebApplicationFactory.Services.CreateAsyncScope()) + { + var db = scope.ServiceProvider.GetRequiredService(); + await db.UpsertControlStatsAsync([ + // Midnight in Kolkata (UTC+5:30) is 18:30 UTC, in the middle of an hour + Delta(user.Id, shockerId, user.Id, new DateTime(2026, 1, 1, 18, 15, 0, DateTimeKind.Utc), count: 1), + Delta(user.Id, shockerId, user.Id, new DateTime(2026, 1, 1, 18, 45, 0, DateTimeKind.Utc), count: 2) + ]); + } + + using var client = TestHelper.CreateAuthenticatedClient(WebApplicationFactory, user.SessionToken); + + using (var all = await GetStats(client, "?tz=Asia/Kolkata")) + await Assert.That(Days(all)).IsEquivalentTo(new[] { "2026-01-01", "2026-01-02" }); + + using var secondDay = await GetStats(client, "?tz=Asia/Kolkata&from=2026-01-02"); + await Assert.That(Days(secondDay)).IsEquivalentTo(new[] { "2026-01-02" }); + await Assert.That(FindTotals(secondDay.RootElement.GetProperty("received").GetProperty("totals"), "Shock", live: false) + .GetProperty("count").GetInt64()).IsEqualTo(2); + } + + [Test] + public async Task Stats_InvalidTimeZoneOrRange_Returns400() + { + var user = await TestHelper.CreateAndLoginUser(WebApplicationFactory, TestHelper.UniqueUsername("statsbad"), + TestHelper.UniqueEmail("statsbad"), "SecurePassword123#"); + using var client = TestHelper.CreateAuthenticatedClient(WebApplicationFactory, user.SessionToken); + + var badTz = await client.GetAsync("/1/users/self/control-stats?tz=Not/AZone"); + await Assert.That(badTz.StatusCode).IsEqualTo(HttpStatusCode.BadRequest); + + var badRange = await client.GetAsync("/1/users/self/control-stats?from=2026-02-01&to=2026-01-01"); + await Assert.That(badRange.StatusCode).IsEqualTo(HttpStatusCode.BadRequest); + } + + [Test] + public async Task Stats_Unauthenticated_Returns401() + { + using var client = WebApplicationFactory.CreateClient(); + var response = await client.GetAsync("/1/users/self/control-stats"); + await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.Unauthorized); + } + + private static ControlStatDelta Delta(Guid ownerId, Guid shockerId, Guid? controllerId, DateTime at, long count, byte intensity = 50) => + new(ownerId, shockerId, controllerId, at, ControlType.Shock, Live: false, count, DurationMs: count * 1000, + IntensityMs: count * 1000 * intensity, intensity); + + private async Task CreateShocker(Guid ownerId, string name) + { + var (deviceId, _) = await TestHelper.CreateDeviceInDb(WebApplicationFactory, ownerId); + + await using var scope = WebApplicationFactory.Services.CreateAsyncScope(); + var db = scope.ServiceProvider.GetRequiredService(); + + var shockerId = Guid.CreateVersion7(); + db.Shockers.Add(new Shocker + { + Id = shockerId, + Name = name, + Model = ShockerModelType.CaiXianlin, + RfId = 1234, + DeviceId = deviceId + }); + await db.SaveChangesAsync(); + return shockerId; + } + + private static async Task GetStats(HttpClient client, string query = "") + { + var response = await client.GetAsync("/1/users/self/control-stats" + query); + await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.OK); + return JsonDocument.Parse(await response.Content.ReadAsStringAsync()); + } + + private static JsonElement FindTotals(JsonElement totals, string type, bool live) => totals.EnumerateArray() + .Single(x => x.GetProperty("type").GetString() == type && x.GetProperty("live").GetBoolean() == live); + + private static string[] Days(JsonDocument doc) => doc.RootElement.GetProperty("received").GetProperty("days") + .EnumerateArray().Select(x => x.GetProperty("date").GetString()!).ToArray(); +} diff --git a/API.IntegrationTests/WebApplicationFactory.cs b/API.IntegrationTests/WebApplicationFactory.cs index 68ccadb5..4e776601 100644 --- a/API.IntegrationTests/WebApplicationFactory.cs +++ b/API.IntegrationTests/WebApplicationFactory.cs @@ -113,6 +113,8 @@ protected override void ConfigureWebHost(IWebHostBuilder builder) RateLimitPartition.GetNoLimiter("test-token-reporting-no-limit")); options.AddPolicy("shocker-logs", _ => RateLimitPartition.GetNoLimiter("test-shocker-logs-no-limit")); + options.AddPolicy("control-stats", _ => + RateLimitPartition.GetNoLimiter("test-control-stats-no-limit")); }); }); } diff --git a/API/Controller/Integrations/GetControlStatChanges.cs b/API/Controller/Integrations/GetControlStatChanges.cs new file mode 100644 index 00000000..29b8460e --- /dev/null +++ b/API/Controller/Integrations/GetControlStatChanges.cs @@ -0,0 +1,146 @@ +using System.Net.Mime; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.RateLimiting; +using Microsoft.EntityFrameworkCore; +using OpenShock.API.Errors; +using OpenShock.API.Models.Requests; +using OpenShock.API.Models.Response; +using OpenShock.Common.Extensions; +using OpenShock.Common.Models; +using OpenShock.Common.OpenShockDb; +using OpenShock.Common.Problems; + +using OpenShock.Internal.Common.Problems; + +namespace OpenShock.API.Controller.Integrations; + +public sealed partial class IntegrationsController +{ + /// + /// Lets a service holding many users keep their lifetime control stats in sync with a single call per tick. + /// Returns the lifetime totals of every requested user whose stats changed since the given cursor. + /// + /// + /// Requires an automation token granting ControlStatsRead. "Changed since" is answered from when each + /// stats row was last written rather than the time it covers, so live control stats written late (e.g. after a + /// database outage) are still picked up. Rows deleted along with their owner lower the totals of everyone who + /// controlled them without leaving a row behind, those are picked up from . The + /// lookback is widened by to cover writes still committing while the previous call + /// read, so a user can be reported again although nothing changed. + /// + /// The changed users' totals and the cursor to pass next time. + /// No automation token granting ControlStatsRead was presented. + [HttpPost("control-stats/changes")] + [EnableRateLimiting("control-stats")] + [ProducesResponseType(StatusCodes.Status200OK, MediaTypeNames.Application.Json)] + [ProducesResponseType(StatusCodes.Status403Forbidden, MediaTypeNames.Application.ProblemJson)] // MissingCapability + public async Task GetControlStatChanges(ControlStatChangesRequest body, CancellationToken cancellationToken) + { + if (!HttpContext.IsBypassed(AutomationTokenType.ControlStatsRead)) + return Problem(ControlStatsError.MissingCapability); + + // Taken before querying, so anything written while we read is picked up by the next call + var cursor = DateTime.UtcNow; + var userIds = body.UserIds.Distinct().ToArray(); + + Guid[] changed; + if (body.Since is not null) + { + // A cursor without offset is the UTC one we handed out, not server local time + var since = body.Since.Value.Kind == DateTimeKind.Unspecified + ? DateTime.SpecifyKind(body.Since.Value, DateTimeKind.Utc) + : body.Since.Value.ToUniversalTime(); + // A cursor at the very beginning of time can't be widened any further + var from = since - DateTime.MinValue > ChangeOverlap + ? since - ChangeOverlap + : DateTime.SpecifyKind(DateTime.MinValue, DateTimeKind.Utc); + var recent = _db.ControlStats.Where(x => x.UpdatedAt >= from); + + var owners = await recent + .Where(x => userIds.Contains(x.OwnerUserId)) + .Select(x => x.OwnerUserId) + .Distinct() + .ToArrayAsync(cancellationToken); + var controllers = await recent + .Where(x => x.ControllerUserId != null && userIds.Contains(x.ControllerUserId.Value)) + .Select(x => x.ControllerUserId!.Value) + .Distinct() + .ToArrayAsync(cancellationToken); + + // Deletions leave no row to be updated, so they are recorded separately + var removed = await _db.ControlStatRemovals + .Where(x => x.RemovedAt >= from && userIds.Contains(x.UserId)) + .Select(x => x.UserId) + .ToArrayAsync(cancellationToken); + + changed = owners.Union(controllers).Union(removed).ToArray(); + } + else + { + // Initial sync, everyone requested is reported, users without any stats with empty totals + changed = userIds; + } + + if (changed.Length == 0) + return Ok(new ControlStatChangesResponse { Cursor = cursor, Users = [] }); + + var received = await _db.ControlStats + .Where(x => changed.Contains(x.OwnerUserId)) + .GroupBy(x => new { UserId = x.OwnerUserId, x.Type, x.Live }) + .Select(g => new TotalsRow(g.Key.UserId, g.Key.Type, g.Key.Live, g.Sum(x => x.Count), + g.Sum(x => x.DurationMs), g.Sum(x => x.IntensityMs), g.Max(x => x.MaxIntensity))) + .ToArrayAsync(cancellationToken); + + var given = await _db.ControlStats + .Where(x => x.ControllerUserId != null && changed.Contains(x.ControllerUserId.Value)) + .GroupBy(x => new { UserId = x.ControllerUserId!.Value, x.Type, x.Live }) + .Select(g => new TotalsRow(g.Key.UserId, g.Key.Type, g.Key.Live, g.Sum(x => x.Count), + g.Sum(x => x.DurationMs), g.Sum(x => x.IntensityMs), g.Max(x => x.MaxIntensity))) + .ToArrayAsync(cancellationToken); + + var receivedByUser = received.ToLookup(x => x.UserId); + var givenByUser = given.ToLookup(x => x.UserId); + + return Ok(new ControlStatChangesResponse + { + Cursor = cursor, + Users = changed + .Select(id => new UserControlStatTotals + { + UserId = id, + Received = ToTotals(receivedByUser[id]), + Given = ToTotals(givenByUser[id]) + }) + .ToArray() + }); + } + + /// + /// Well above how long a stats write takes to commit, and any clock skew between the API and the database, since + /// is stamped by the database when its transaction starts. + /// + private static readonly TimeSpan ChangeOverlap = TimeSpan.FromMinutes(5); + + private static ControlStatTotals[] ToTotals(IEnumerable rows) => rows + .OrderBy(x => x.Type) + .ThenBy(x => x.Live) + .Select(x => new ControlStatTotals + { + Type = x.Type, + Live = x.Live, + Count = x.Count, + DurationMs = x.DurationMs, + AverageIntensity = x.DurationMs == 0 ? 0 : (double)x.IntensityMs / x.DurationMs, + MaxIntensity = x.MaxIntensity + }) + .ToArray(); + + private sealed record TotalsRow( + Guid UserId, + ControlType Type, + bool Live, + long Count, + long DurationMs, + long IntensityMs, + byte MaxIntensity); +} diff --git a/API/Controller/Integrations/_ApiController.cs b/API/Controller/Integrations/_ApiController.cs new file mode 100644 index 00000000..8a49e6ed --- /dev/null +++ b/API/Controller/Integrations/_ApiController.cs @@ -0,0 +1,22 @@ +using Microsoft.AspNetCore.Mvc; +using OpenShock.Common; +using OpenShock.Common.OpenShockDb; + +namespace OpenShock.API.Controller.Integrations; + +/// +/// Endpoints for first party services (e.g. the Discord bot). Not tied to a user session, every action checks +/// for an automation token that grants the capability it needs. +/// +[ApiController] +[Tags("Integrations")] +[Route("/{version:apiVersion}/integrations")] +public sealed partial class IntegrationsController : OpenShockControllerBase +{ + private readonly OpenShockContext _db; + + public IntegrationsController(OpenShockContext db) + { + _db = db; + } +} diff --git a/API/Controller/Users/GetSelfControlStats.cs b/API/Controller/Users/GetSelfControlStats.cs new file mode 100644 index 00000000..059588e5 --- /dev/null +++ b/API/Controller/Users/GetSelfControlStats.cs @@ -0,0 +1,207 @@ +using System.ComponentModel.DataAnnotations; +using System.Net.Mime; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.RateLimiting; +using Microsoft.EntityFrameworkCore; +using Npgsql; +using OpenShock.API.Errors; +using OpenShock.API.Models.Response; +using OpenShock.Common.Models; +using OpenShock.Common.OpenShockDb; +using OpenShock.Common.Problems; +using OpenShock.Common.Utils; + +using OpenShock.Internal.Common.Problems; + +namespace OpenShock.API.Controller.Users; + +public sealed partial class UsersController +{ + /// + /// Get control statistics for the current user, both for their own shockers and for controls they sent. + /// + /// IANA time zone the daily breakdown and the date range are in, e.g. Europe/Berlin, defaults to UTC. + /// First day to include (inclusive), defaults to all time. + /// Last day to include (inclusive), defaults to today. + /// + /// The statistics. + /// Unknown time zone or invalid range. + [HttpGet("self/control-stats")] + [EnableRateLimiting("control-stats")] + [ProducesResponseType(StatusCodes.Status200OK, MediaTypeNames.Application.Json)] + [ProducesResponseType(StatusCodes.Status400BadRequest, MediaTypeNames.Application.ProblemJson)] // InvalidTimeZone, InvalidRange + public async Task GetSelfControlStats( + [FromQuery, MaxLength(64)] string? tz, + [FromQuery] DateOnly? from, + [FromQuery] DateOnly? to, + CancellationToken cancellationToken) + { + tz ??= "UTC"; + if (from > to) return Problem(ControlStatsError.InvalidRange); + + // Postgres is the one interpreting the time zone, so it is the one validating it, and while at it turns the + // local range into UTC so the filters below hit the bucket index instead of converting every row. "Valid" is + // there since AT TIME ZONE is skipped for null input, and so wouldn't reject an unknown zone without a range. + TimeZoneRange range; + try + { + range = await _db.Database.SqlQuery( + $""" + SELECT now() AT TIME ZONE {tz} IS NOT NULL AS "Valid", + {from}::date::timestamp AT TIME ZONE {tz} AS "FromUtc", + ({to}::date + 1)::timestamp AT TIME ZONE {tz} AS "ToUtcExclusive" + """).SingleAsync(cancellationToken); + } + catch (PostgresException e) when (e.SqlState == PostgresErrorCodes.InvalidParameterValue) + { + return Problem(ControlStatsError.InvalidTimeZone); + } + + var stats = _db.ControlStats.AsQueryable(); + if (range.FromUtc is not null) stats = stats.Where(x => x.Bucket >= range.FromUtc.Value); + if (range.ToUtcExclusive is not null) stats = stats.Where(x => x.Bucket < range.ToUtcExclusive.Value); + + // Fine grained enough to derive every received view in memory, the row count is bounded by + // days * shockers * controllers * types rather than by activity + var received = await stats + .Where(x => x.OwnerUserId == CurrentUser.Id) + .GroupBy(x => new + { + Day = TimeZoneInfo.ConvertTimeBySystemTimeZoneId(x.Bucket, tz).Date, + x.ShockerId, + x.ControllerUserId, + x.Type, + x.Live + }) + .Select(g => new StatRow( + new ReceivedKey(g.Key.Day, g.Key.ShockerId, g.Key.ControllerUserId), + g.Key.Type, + g.Key.Live, + g.Sum(x => x.Count), + g.Sum(x => x.DurationMs), + g.Sum(x => x.IntensityMs), + g.Max(x => x.MaxIntensity))) + .ToArrayAsync(cancellationToken); + + var given = await stats + .Where(x => x.ControllerUserId == CurrentUser.Id) + .GroupBy(x => new { x.OwnerUserId, x.Type, x.Live }) + .Select(g => new StatRow( + new GivenKey(g.Key.OwnerUserId), + g.Key.Type, + g.Key.Live, + g.Sum(x => x.Count), + g.Sum(x => x.DurationMs), + g.Sum(x => x.IntensityMs), + g.Max(x => x.MaxIntensity))) + .ToArrayAsync(cancellationToken); + + var shockerIds = received.Select(x => x.Key.ShockerId).OfType().Distinct().ToArray(); + var shockerNames = await _db.Shockers + .Where(x => shockerIds.Contains(x.Id)) + .Select(x => new { x.Id, x.Name }) + .ToDictionaryAsync(x => x.Id, x => x.Name, cancellationToken); + + var userIds = received.Select(x => x.Key.ControllerUserId).OfType() + .Concat(given.Select(x => x.Key.OwnerUserId)) + .Distinct() + .ToArray(); + var users = (await _db.Users + .Where(x => userIds.Contains(x.Id)) + .Select(x => new { x.Id, x.Name, x.Email }) + .ToArrayAsync(cancellationToken)) + .ToDictionary(x => x.Id, x => new BasicUserInfo + { + Id = x.Id, + Name = x.Name, + Image = GravatarUtils.GetUserImageUrl(x.Email) + }); + + return Ok(new ControlStatsResponse + { + Received = new ReceivedControlStats + { + Totals = Totals(received), + Shockers = received + .GroupBy(x => x.Key.ShockerId) + .Select(g => new ShockerControlStats + { + ShockerId = g.Key, + ShockerName = g.Key is { } id ? shockerNames.GetValueOrDefault(id) : null, + Stats = Totals(g) + }) + .ToArray(), + Controllers = received + .GroupBy(x => x.Key.ControllerUserId) + .Select(g => new UserControlStats + { + UserId = g.Key, + User = g.Key is { } id ? users.GetValueOrDefault(id) : null, + Stats = Totals(g) + }) + .ToArray(), + Days = received + .GroupBy(x => x.Key.Day) + .OrderBy(g => g.Key) + .Select(g => new DailyControlStats + { + Date = DateOnly.FromDateTime(g.Key), + Stats = Totals(g) + }) + .ToArray() + }, + Given = new GivenControlStats + { + Totals = Totals(given), + Owners = given + .GroupBy(x => x.Key.OwnerUserId) + .Select(g => new UserControlStats + { + UserId = g.Key, + User = users.GetValueOrDefault(g.Key), + Stats = Totals(g) + }) + .ToArray() + } + }); + } + + private static ControlStatTotals[] Totals(IEnumerable> rows) => rows + .GroupBy(x => (x.Type, x.Live)) + .OrderBy(g => g.Key.Type) + .ThenBy(g => g.Key.Live) + .Select(g => + { + var durationMs = g.Sum(x => x.DurationMs); + return new ControlStatTotals + { + Type = g.Key.Type, + Live = g.Key.Live, + Count = g.Sum(x => x.Count), + DurationMs = durationMs, + AverageIntensity = durationMs == 0 ? 0 : (double)g.Sum(x => x.IntensityMs) / durationMs, + MaxIntensity = g.Max(x => x.MaxIntensity) + }; + }) + .ToArray(); + + private sealed class TimeZoneRange + { + public required bool Valid { get; init; } + public required DateTime? FromUtc { get; init; } + public required DateTime? ToUtcExclusive { get; init; } + } + + private sealed record ReceivedKey(DateTime Day, Guid? ShockerId, Guid? ControllerUserId); + + private sealed record GivenKey(Guid OwnerUserId); + + private sealed record StatRow( + TKey Key, + ControlType Type, + bool Live, + long Count, + long DurationMs, + long IntensityMs, + byte MaxIntensity); +} diff --git a/API/Controller/Users/_ApiController.cs b/API/Controller/Users/_ApiController.cs index 741fdf78..9d4b7e83 100644 --- a/API/Controller/Users/_ApiController.cs +++ b/API/Controller/Users/_ApiController.cs @@ -2,6 +2,7 @@ using Microsoft.AspNetCore.Mvc; using OpenShock.Common.Authentication; using OpenShock.Common.Authentication.ControllerBase; +using OpenShock.Common.OpenShockDb; namespace OpenShock.API.Controller.Users; @@ -11,10 +12,12 @@ namespace OpenShock.API.Controller.Users; [Authorize(AuthenticationSchemes = OpenShockAuthSchemes.UserSessionApiTokenCombo)] public sealed partial class UsersController : AuthenticatedSessionControllerBase { + private readonly OpenShockContext _db; private readonly ILogger _logger; - public UsersController(ILogger logger) + public UsersController(OpenShockContext db, ILogger logger) { + _db = db; _logger = logger; } } \ No newline at end of file diff --git a/API/Errors/ControlStatsError.cs b/API/Errors/ControlStatsError.cs new file mode 100644 index 00000000..949796c0 --- /dev/null +++ b/API/Errors/ControlStatsError.cs @@ -0,0 +1,13 @@ +using System.Net; +using OpenShock.Common.Problems; + +using OpenShock.Internal.Common.Problems; + +namespace OpenShock.API.Errors; + +public static class ControlStatsError +{ + public static OpenShockProblem InvalidTimeZone => new("ControlStats.InvalidTimeZone", "Unknown time zone, expected an IANA time zone id like Europe/Berlin", HttpStatusCode.BadRequest); + public static OpenShockProblem MissingCapability => new("ControlStats.MissingCapability", "An automation token granting ControlStatsRead is required", HttpStatusCode.Forbidden); + public static OpenShockProblem InvalidRange => new("ControlStats.InvalidRange", "The from date must not be after the to date", HttpStatusCode.BadRequest); +} diff --git a/API/Models/Requests/ControlStatChangesRequest.cs b/API/Models/Requests/ControlStatChangesRequest.cs new file mode 100644 index 00000000..a11bcd2f --- /dev/null +++ b/API/Models/Requests/ControlStatChangesRequest.cs @@ -0,0 +1,16 @@ +using System.ComponentModel.DataAnnotations; + +namespace OpenShock.API.Models.Requests; + +public sealed class ControlStatChangesRequest +{ + /// The cursor returned by the previous call, omit for an initial sync of every requested user. + public DateTime? Since { get; init; } + + /// The users the caller is interested in, only these are ever reported. + [Required] + [MaxLength(MaxUserIds)] + public required Guid[] UserIds { get; init; } + + public const int MaxUserIds = 1000; +} diff --git a/API/Models/Response/ControlStatsResponse.cs b/API/Models/Response/ControlStatsResponse.cs new file mode 100644 index 00000000..1867d05e --- /dev/null +++ b/API/Models/Response/ControlStatsResponse.cs @@ -0,0 +1,90 @@ +using OpenShock.Common.Models; +using OpenShock.Common.OpenShockDb; + +namespace OpenShock.API.Models.Response; + +public sealed class ControlStatsResponse +{ + /// Activity on shockers you own. + public required ReceivedControlStats Received { get; init; } + + /// Activity you sent to shockers, your own included. + public required GivenControlStats Given { get; init; } +} + +public sealed class ReceivedControlStats +{ + public required ControlStatTotals[] Totals { get; init; } + public required ShockerControlStats[] Shockers { get; init; } + public required UserControlStats[] Controllers { get; init; } + public required DailyControlStats[] Days { get; init; } +} + +public sealed class GivenControlStats +{ + public required ControlStatTotals[] Totals { get; init; } + public required UserControlStats[] Owners { get; init; } +} + +/// Aggregated activity of one control type, split by whether it was live control. +public sealed class ControlStatTotals +{ + public required ControlType Type { get; init; } + public required bool Live { get; init; } + + /// Number of controls, for live control the number of continuous bursts. + public required long Count { get; init; } + + public required long DurationMs { get; init; } + + /// Average intensity weighted by duration. + public required double AverageIntensity { get; init; } + + public required byte MaxIntensity { get; init; } +} + +public sealed class ShockerControlStats +{ + /// Null for shockers that have since been deleted, these are combined into one entry. + public required Guid? ShockerId { get; init; } + public required string? ShockerName { get; init; } + public required ControlStatTotals[] Stats { get; init; } +} + +public sealed class UserControlStats +{ + /// Null for guests (public shares). + public required Guid? UserId { get; init; } + + /// Null for guests and deleted users. + public required BasicUserInfo? User { get; init; } + + public required ControlStatTotals[] Stats { get; init; } +} + +public sealed class DailyControlStats +{ + /// Day in the requested time zone. + public required DateOnly Date { get; init; } + public required ControlStatTotals[] Stats { get; init; } +} + +public sealed class ControlStatChangesResponse +{ + /// Pass as since on the next call. + public required DateTime Cursor { get; init; } + + public required UserControlStatTotals[] Users { get; init; } +} + +/// Lifetime totals of one user, the same views as without breakdowns. +public sealed class UserControlStatTotals +{ + public required Guid UserId { get; init; } + + /// Activity on shockers the user owns. + public required ControlStatTotals[] Received { get; init; } + + /// Activity the user sent to shockers, their own included. + public required ControlStatTotals[] Given { get; init; } +} diff --git a/Common.Tests/Services/ControlStatsAccumulatorTests.cs b/Common.Tests/Services/ControlStatsAccumulatorTests.cs new file mode 100644 index 00000000..475e5caf --- /dev/null +++ b/Common.Tests/Services/ControlStatsAccumulatorTests.cs @@ -0,0 +1,52 @@ +using OpenShock.Common.OpenShockDb; +using OpenShock.Common.Services.ControlStats; + +namespace OpenShock.Common.Tests.Services; + +public class ControlStatsAccumulatorTests +{ + private static readonly Guid Owner = Guid.CreateVersion7(); + private static readonly Guid Shocker = Guid.CreateVersion7(); + private static readonly Guid Controller = Guid.CreateVersion7(); + + private static ControlStatDelta Delta(DateTime at, ControlType type = ControlType.Shock, long count = 1, byte intensity = 50) => + new(Owner, Shocker, Controller, at, type, Live: true, count, DurationMs: 100, IntensityMs: intensity * 100L, intensity); + + [Test] + public async Task Add_MergesWithinTheSameBucket() + { + var accumulator = new ControlStatsAccumulator(); + accumulator.Add(Delta(new DateTime(2026, 1, 1, 10, 31, 0, DateTimeKind.Utc), count: 1, intensity: 20)); + accumulator.Add(Delta(new DateTime(2026, 1, 1, 10, 44, 59, DateTimeKind.Utc), count: 0, intensity: 80)); + + var merged = (await Assert.That(accumulator.DequeueAll()).HasSingleItem())!; + + await Assert.That(merged.Bucket).IsEqualTo(new DateTime(2026, 1, 1, 10, 30, 0, DateTimeKind.Utc)); + await Assert.That(merged.Count).IsEqualTo(1); + await Assert.That(merged.DurationMs).IsEqualTo(200); + await Assert.That(merged.IntensityMs).IsEqualTo(10_000); + await Assert.That(merged.MaxIntensity).IsEqualTo((byte)80); + } + + [Test] + public async Task Add_SplitsAcrossBucketsAndTypes_AndIgnoresStop() + { + var accumulator = new ControlStatsAccumulator(); + accumulator.Add(Delta(new DateTime(2026, 1, 1, 10, 44, 59, DateTimeKind.Utc))); + accumulator.Add(Delta(new DateTime(2026, 1, 1, 10, 45, 0, DateTimeKind.Utc))); + accumulator.Add(Delta(new DateTime(2026, 1, 1, 10, 45, 0, DateTimeKind.Utc), ControlType.Vibrate)); + accumulator.Add(Delta(new DateTime(2026, 1, 1, 10, 45, 0, DateTimeKind.Utc), ControlType.Stop)); + + await Assert.That(accumulator.DequeueAll().Length).IsEqualTo(3); + } + + [Test] + public async Task DequeueAll_Drains() + { + var accumulator = new ControlStatsAccumulator(); + accumulator.Add(Delta(DateTime.UtcNow)); + + await Assert.That(accumulator.DequeueAll().Length).IsEqualTo(1); + await Assert.That(accumulator.DequeueAll()).IsEmpty(); + } +} diff --git a/Common/Hubs/PublicShareHub.cs b/Common/Hubs/PublicShareHub.cs index faaaf475..1d87ee29 100644 --- a/Common/Hubs/PublicShareHub.cs +++ b/Common/Hubs/PublicShareHub.cs @@ -123,8 +123,8 @@ public Task Control(IReadOnlyList shocks) { if (!TokenPermissions.IsAllowedAllowOrNull(PermissionType.Shockers_Use)) return Task.CompletedTask; - return _controlSender.ControlPublicShare(shocks, CustomData.CachedControlLogSender, _userHub.Clients, - CustomData.PublicShareId); + return _controlSender.ControlPublicShare(shocks, CustomData.CachedControlLogSender, CustomData.User?.Id, + _userHub.Clients, CustomData.PublicShareId); } private CustomDataHolder CustomData => (CustomDataHolder)Context.Items[PublicShareCustomData]!; diff --git a/Common/Migrations/20261016142540_AddControlStats.Designer.cs b/Common/Migrations/20261016142540_AddControlStats.Designer.cs new file mode 100644 index 00000000..d2f96fa4 --- /dev/null +++ b/Common/Migrations/20261016142540_AddControlStats.Designer.cs @@ -0,0 +1,1898 @@ +// +using System; +using System.Collections.Generic; +using System.Net; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using OpenShock.Common.OpenShockDb; + +#nullable disable + +namespace OpenShock.Common.Migrations; + +[DbContext(typeof(MigrationOpenShockContext))] +[Migration("20261016142540_AddControlStats")] +partial class AddControlStats +{ + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("Npgsql:CollationDefinition:public.ndcoll", "und-u-ks-level2,und-u-ks-level2,icu,False") + .HasAnnotation("ProductVersion", "11.0.0-rc.1.26425.128") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "audit_action", new[] { "login", "logout", "password_changed", "email_change_requested", "email_changed", "username_changed", "api_token_created", "api_token_deleted", "oauth_connected", "oauth_disconnected", "account_deactivated", "account_reactivated", "account_deleted", "automation_token_created", "automation_token_updated", "automation_token_rotated", "automation_token_deleted", "automation_token_used" }); + NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "automation_token_type", new[] { "turnstile", "rate_limit", "control_stats_read" }); + NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "configuration_value_type", new[] { "string", "bool", "int", "float", "json" }); + NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "control_limit_mode", new[] { "clamp", "lerp" }); + NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "control_type", new[] { "stop", "shock", "vibrate", "sound" }); + NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "email_status", new[] { "pending", "sending", "sent", "failed", "skipped" }); + NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "email_type", new[] { "account_activation", "password_reset", "email_verification", "email_change_notice" }); + NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "match_type_enum", new[] { "exact", "contains" }); + NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "ota_update_status", new[] { "started", "running", "finished", "error", "timeout" }); + NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "password_encryption_type", new[] { "bcrypt_enhanced", "pbkdf2" }); + NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "permission_type", new[] { "shockers.use", "shockers.edit", "shockers.pause", "devices.edit", "devices.auth", "usershares.edit", "usershares.pause", "publicshares.pause", "publicshares.edit" }); + NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "role_type", new[] { "support", "staff", "admin", "system" }); + NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "shocker_model_type", new[] { "cai_xianlin", "petrainer", "petrainer_998dr", "wellturn_t330" }); + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Microsoft.AspNetCore.DataProtection.EntityFrameworkCore.DataProtectionKey", b => + { + var id = b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(id); + + b.Property("FriendlyName") + .HasColumnType("text"); + + b.Property("Xml") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.ToTable("DataProtectionKeys"); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.AdminUsersView", b => + { + b.Property("ActivatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("activated_at"); + + b.Property("ApiTokenCount") + .HasColumnType("integer") + .HasColumnName("api_token_count"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CreatedByAutomationTokenId") + .HasColumnType("uuid") + .HasColumnName("created_by_automation_token_id"); + + b.Property("DeactivatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("deactivated_at"); + + b.Property("DeactivatedByUserId") + .HasColumnType("uuid") + .HasColumnName("deactivated_by_user_id"); + + b.Property("DeviceCount") + .HasColumnType("integer") + .HasColumnName("device_count"); + + b.Property("Email") + .IsRequired() + .HasColumnType("character varying") + .HasColumnName("email"); + + b.Property("EmailChangeRequestCount") + .HasColumnType("integer") + .HasColumnName("email_change_request_count"); + + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Name") + .IsRequired() + .HasColumnType("character varying") + .HasColumnName("name"); + + b.Property("NameChangeRequestCount") + .HasColumnType("integer") + .HasColumnName("name_change_request_count"); + + b.Property("PasswordHashType") + .HasColumnType("character varying") + .HasColumnName("password_hash_type"); + + b.Property("PasswordResetCount") + .HasColumnType("integer") + .HasColumnName("password_reset_count"); + + b.Property("Roles") + .IsRequired() + .HasColumnType("role_type[]") + .HasColumnName("roles"); + + b.Property("ShockerControlLogCount") + .HasColumnType("integer") + .HasColumnName("shocker_control_log_count"); + + b.Property("ShockerCount") + .HasColumnType("integer") + .HasColumnName("shocker_count"); + + b.Property("ShockerPublicShareCount") + .HasColumnType("integer") + .HasColumnName("shocker_public_share_count"); + + b.Property("ShockerUserShareCount") + .HasColumnType("integer") + .HasColumnName("shocker_user_share_count"); + + b.ToTable((string)null); + + b.ToView("admin_users_view", (string)null); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.ApiToken", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("CreatedByIp") + .IsRequired() + .HasColumnType("inet") + .HasColumnName("created_by_ip"); + + b.Property("LastUsed") + .HasColumnType("timestamp with time zone") + .HasColumnName("last_used"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("name"); + + b.Property>("Permissions") + .IsRequired() + .HasColumnType("permission_type[]") + .HasColumnName("permissions"); + + b.Property("ShockerControlDurationMax") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(65535) + .HasColumnName("shocker_control_duration_max"); + + b.Property("ShockerControlDurationMin") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(300) + .HasColumnName("shocker_control_duration_min"); + + b.Property("ShockerControlDurationMode") + .ValueGeneratedOnAdd() + .HasColumnType("control_limit_mode") + .HasDefaultValue(ControlLimitMode.Clamp) + .HasColumnName("shocker_control_duration_mode"); + + b.Property("ShockerControlIntensityMax") + .ValueGeneratedOnAdd() + .HasColumnType("smallint") + .HasDefaultValue((byte)100) + .HasColumnName("shocker_control_intensity_max"); + + b.Property("ShockerControlIntensityMin") + .ValueGeneratedOnAdd() + .HasColumnType("smallint") + .HasDefaultValue((byte)0) + .HasColumnName("shocker_control_intensity_min"); + + b.Property("ShockerControlIntensityMode") + .ValueGeneratedOnAdd() + .HasColumnType("control_limit_mode") + .HasDefaultValue(ControlLimitMode.Clamp) + .HasColumnName("shocker_control_intensity_mode"); + + b.Property("ShockerControlPaused") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("shocker_control_paused"); + + b.Property("TokenHash") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("token_hash") + .UseCollation("C"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.Property("ValidUntil") + .HasColumnType("timestamp with time zone") + .HasColumnName("valid_until"); + + b.HasKey("Id") + .HasName("api_tokens_pkey"); + + b.HasIndex("TokenHash") + .IsUnique(); + + b.HasIndex("UserId"); + + b.HasIndex("ValidUntil"); + + b.ToTable("api_tokens"); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.ApiTokenReport", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AffectedCount") + .HasColumnType("integer") + .HasColumnName("affected_count"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("IpAddress") + .IsRequired() + .HasColumnType("inet") + .HasColumnName("ip_address"); + + b.Property("IpCountry") + .HasColumnType("text") + .HasColumnName("ip_country"); + + b.Property("SubmittedCount") + .HasColumnType("integer") + .HasColumnName("submitted_count"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("Id") + .HasName("api_token_reports_pkey"); + + b.HasIndex("UserId"); + + b.ToTable("api_token_reports"); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.AutomationToken", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AutoCleanupAfter") + .HasColumnType("interval") + .HasColumnName("auto_cleanup_after"); + + b.Property("AutoCleanupUsers") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("auto_cleanup_users"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("LastRotatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("last_rotated_at"); + + b.Property("LastUsedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("last_used_at"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("name"); + + b.Property("TokenHash") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("token_hash") + .UseCollation("C"); + + b.Property>("Types") + .IsRequired() + .HasColumnType("automation_token_type[]") + .HasColumnName("types"); + + b.Property("UseCount") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasDefaultValue(0L) + .HasColumnName("use_count"); + + b.HasKey("Id") + .HasName("automation_tokens_pkey"); + + b.HasIndex("TokenHash") + .IsUnique(); + + b.ToTable("automation_tokens"); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.ConfigurationItem", b => + { + b.Property("Name") + .HasColumnType("text") + .HasColumnName("name") + .UseCollation("C"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("Description") + .IsRequired() + .HasColumnType("text") + .HasColumnName("description"); + + b.Property("Type") + .HasColumnType("configuration_value_type") + .HasColumnName("type"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("Value") + .IsRequired() + .HasColumnType("text") + .HasColumnName("value"); + + b.HasKey("Name") + .HasName("configuration_pkey"); + + b.ToTable("configuration"); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.ControlStat", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Bucket") + .HasColumnType("timestamp with time zone") + .HasColumnName("bucket"); + + b.Property("ControllerUserId") + .HasColumnType("uuid") + .HasColumnName("controller_user_id"); + + b.Property("Count") + .HasColumnType("bigint") + .HasColumnName("count"); + + b.Property("DurationMs") + .HasColumnType("bigint") + .HasColumnName("duration_ms"); + + b.Property("IntensityMs") + .HasColumnType("bigint") + .HasColumnName("intensity_ms"); + + b.Property("Live") + .HasColumnType("boolean") + .HasColumnName("live"); + + b.Property("MaxIntensity") + .HasColumnType("smallint") + .HasColumnName("max_intensity"); + + b.Property("OwnerUserId") + .HasColumnType("uuid") + .HasColumnName("owner_user_id"); + + b.Property("ShockerId") + .HasColumnType("uuid") + .HasColumnName("shocker_id"); + + b.Property("Type") + .HasColumnType("control_type") + .HasColumnName("type"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.HasKey("Id") + .HasName("control_stats_pkey"); + + b.HasIndex("ShockerId"); + + b.HasIndex("UpdatedAt"); + + b.HasIndex("ControllerUserId", "Bucket"); + + b.HasIndex("OwnerUserId", "Bucket"); + + var index = b.HasIndex("OwnerUserId", "ShockerId", "ControllerUserId", "Bucket", "Type", "Live") + .IsUnique() + .HasDatabaseName("ix_control_stats_upsert_key") + .HasFilter("shocker_id IS NOT NULL"); + + NpgsqlIndexBuilderExtensions.AreNullsDistinct(index, false); + + b.ToTable("control_stats"); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.ControlStatBatch", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AppliedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("applied_at"); + + b.HasKey("Id") + .HasName("control_stat_batches_pkey"); + + b.HasIndex("AppliedAt"); + + b.ToTable("control_stat_batches"); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.ControlStatRemoval", b => + { + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.Property("RemovedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("removed_at"); + + b.HasKey("UserId") + .HasName("control_stat_removals_pkey"); + + b.HasIndex("RemovedAt"); + + b.ToTable("control_stat_removals"); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.Device", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("name"); + + b.Property("OwnerId") + .HasColumnType("uuid") + .HasColumnName("owner_id"); + + b.Property("Token") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("token") + .UseCollation("C"); + + b.HasKey("Id") + .HasName("devices_pkey"); + + b.HasIndex("OwnerId"); + + b.HasIndex("Token") + .IsUnique(); + + b.ToTable("devices"); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.DeviceOtaUpdate", b => + { + b.Property("DeviceId") + .HasColumnType("uuid") + .HasColumnName("device_id"); + + b.Property("UpdateId") + .HasColumnType("integer") + .HasColumnName("update_id"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("Message") + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("message"); + + b.Property("Status") + .HasColumnType("ota_update_status") + .HasColumnName("status"); + + b.Property("Version") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("version"); + + b.HasKey("DeviceId", "UpdateId") + .HasName("device_ota_updates_pkey"); + + b.HasIndex(new[] { "CreatedAt" }, "device_ota_updates_created_at_idx"); + + b.ToTable("device_ota_updates"); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.DiscordWebhook", b => + { + b.Property("Name") + .HasColumnType("text") + .HasColumnName("name"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("WebhookId") + .HasColumnType("bigint") + .HasColumnName("webhook_id"); + + b.Property("WebhookToken") + .IsRequired() + .HasColumnType("text") + .HasColumnName("webhook_token"); + + b.HasKey("Name") + .HasName("discord_webhooks_pkey"); + + b.ToTable("discord_webhooks"); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.EmailOutboxMessage", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AttemptCount") + .IsConcurrencyToken() + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0) + .HasColumnName("attempt_count"); + + b.Property("CoalesceKey") + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("coalesce_key"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("FailedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("failed_at"); + + b.Property("LastError") + .HasMaxLength(1024) + .HasColumnType("character varying(1024)") + .HasColumnName("last_error"); + + b.Property("NextAttemptAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("next_attempt_at") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property>("Payload") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("payload"); + + b.Property("Recipient") + .IsRequired() + .HasMaxLength(320) + .HasColumnType("character varying(320)") + .HasColumnName("recipient"); + + b.Property("RecipientName") + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("recipient_name"); + + b.Property("SentAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("sent_at"); + + b.Property("Status") + .HasColumnType("email_status") + .HasColumnName("status"); + + b.Property("Type") + .HasColumnType("email_type") + .HasColumnName("type"); + + b.HasKey("Id") + .HasName("email_outbox_pkey"); + + b.HasIndex("CoalesceKey"); + + b.HasIndex("Recipient"); + + b.HasIndex("Status", "NextAttemptAt"); + + b.ToTable("email_outbox"); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.EmailProviderBlacklist", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("Domain") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)") + .HasColumnName("domain") + .UseCollation("ndcoll"); + + b.HasKey("Id") + .HasName("email_provider_blacklist_pkey"); + + var index = b.HasIndex("Domain") + .IsUnique(); + + NpgsqlIndexBuilderExtensions.UseCollation(index, new[] { "ndcoll" }); + + b.ToTable("email_provider_blacklist"); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.PublicShare", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("expires_at"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("name"); + + b.Property("OwnerId") + .HasColumnType("uuid") + .HasColumnName("owner_id"); + + b.HasKey("Id") + .HasName("public_shares_pkey"); + + b.HasIndex("OwnerId"); + + b.ToTable("public_shares"); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.PublicShareShocker", b => + { + b.Property("PublicShareId") + .HasColumnType("uuid") + .HasColumnName("public_share_id"); + + b.Property("ShockerId") + .HasColumnType("uuid") + .HasColumnName("shocker_id"); + + b.Property("AllowLiveControl") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("allow_livecontrol"); + + b.Property("AllowShock") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(true) + .HasColumnName("allow_shock"); + + b.Property("AllowSound") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(true) + .HasColumnName("allow_sound"); + + b.Property("AllowVibrate") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(true) + .HasColumnName("allow_vibrate"); + + b.Property("Cooldown") + .HasColumnType("integer") + .HasColumnName("cooldown"); + + b.Property("IsPaused") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("is_paused"); + + b.Property("MaxDuration") + .HasColumnType("integer") + .HasColumnName("max_duration"); + + b.Property("MaxIntensity") + .HasColumnType("smallint") + .HasColumnName("max_intensity"); + + b.HasKey("PublicShareId", "ShockerId") + .HasName("public_share_shockers_pkey"); + + b.HasIndex("ShockerId"); + + b.ToTable("public_share_shockers"); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.Shocker", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("DeviceId") + .HasColumnType("uuid") + .HasColumnName("device_id"); + + b.Property("IsPaused") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("is_paused"); + + b.Property("Model") + .HasColumnType("shocker_model_type") + .HasColumnName("model"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("name"); + + b.Property("RfId") + .HasColumnType("integer") + .HasColumnName("rf_id"); + + b.HasKey("Id") + .HasName("shockers_pkey"); + + b.HasIndex("DeviceId"); + + b.ToTable("shockers"); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.ShockerControlLog", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("ControlledByUserId") + .HasColumnType("uuid") + .HasColumnName("controlled_by_user_id"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("CustomName") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("custom_name"); + + b.Property("Duration") + .HasColumnType("bigint") + .HasColumnName("duration"); + + b.Property("Intensity") + .HasColumnType("smallint") + .HasColumnName("intensity"); + + b.Property("LiveControl") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("live_control"); + + b.Property("ShockerId") + .HasColumnType("uuid") + .HasColumnName("shocker_id"); + + b.Property("Type") + .HasColumnType("control_type") + .HasColumnName("type"); + + b.HasKey("Id") + .HasName("shocker_control_logs_pkey"); + + b.HasIndex("ControlledByUserId"); + + b.HasIndex("ShockerId"); + + b.ToTable("shocker_control_logs"); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.ShockerShareCode", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AllowLiveControl") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(true) + .HasColumnName("allow_livecontrol"); + + b.Property("AllowShock") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(true) + .HasColumnName("allow_shock"); + + b.Property("AllowSound") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(true) + .HasColumnName("allow_sound"); + + b.Property("AllowVibrate") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(true) + .HasColumnName("allow_vibrate"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("IsPaused") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("is_paused"); + + b.Property("MaxDuration") + .HasColumnType("integer") + .HasColumnName("max_duration"); + + b.Property("MaxIntensity") + .HasColumnType("smallint") + .HasColumnName("max_intensity"); + + b.Property("ShockerId") + .HasColumnType("uuid") + .HasColumnName("shocker_id"); + + b.HasKey("Id") + .HasName("shocker_share_codes_pkey"); + + b.HasIndex("ShockerId"); + + b.ToTable("shocker_share_codes"); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.User", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("ActivatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("activated_at"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("CreatedByAutomationTokenId") + .HasColumnType("uuid") + .HasColumnName("created_by_automation_token_id"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(320) + .HasColumnType("character varying(320)") + .HasColumnName("email"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("name") + .UseCollation("ndcoll"); + + b.Property("PasswordHash") + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("password_hash") + .UseCollation("C"); + + b.Property>("Roles") + .IsRequired() + .HasColumnType("role_type[]") + .HasColumnName("roles"); + + b.Property("SecurityStamp") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("security_stamp") + .HasDefaultValueSql("gen_random_uuid()"); + + b.HasKey("Id") + .HasName("users_pkey"); + + b.HasIndex("CreatedByAutomationTokenId"); + + b.HasIndex("Email") + .IsUnique(); + + var index = b.HasIndex("Name") + .IsUnique(); + + NpgsqlIndexBuilderExtensions.UseCollation(index, new[] { "ndcoll" }); + + b.ToTable("users"); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.UserActivationRequest", b => + { + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("EmailSendAttempts") + .HasColumnType("integer") + .HasColumnName("email_send_attempts"); + + b.Property("TokenHash") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("token_hash") + .UseCollation("C"); + + b.HasKey("UserId") + .HasName("user_activation_requests_pkey"); + + b.HasIndex("TokenHash") + .IsUnique(); + + b.ToTable("user_activation_requests"); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.UserAuditLog", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Action") + .HasColumnType("audit_action") + .HasColumnName("action"); + + b.Property("ActorId") + .HasColumnType("uuid") + .HasColumnName("actor_id"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("IpAddress") + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ip_address"); + + b.Property("Metadata") + .HasColumnType("jsonb") + .HasColumnName("metadata"); + + b.Property("Reason") + .HasMaxLength(512) + .HasColumnType("character varying(512)") + .HasColumnName("reason"); + + b.Property("UserAgent") + .HasMaxLength(1024) + .HasColumnType("character varying(1024)") + .HasColumnName("user_agent"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("Id") + .HasName("user_audit_logs_pkey"); + + b.HasIndex("ActorId"); + + b.HasIndex("CreatedAt"); + + b.HasIndex("UserId"); + + b.ToTable("user_audit_logs"); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.UserDeactivation", b => + { + b.Property("DeactivatedUserId") + .HasColumnType("uuid") + .HasColumnName("deactivated_user_id"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("DeactivatedByUserId") + .HasColumnType("uuid") + .HasColumnName("deactivated_by_user_id"); + + b.Property("DeleteLater") + .HasColumnType("boolean") + .HasColumnName("delete_later"); + + b.Property("UserModerationId") + .HasColumnType("uuid") + .HasColumnName("user_moderation_id"); + + b.HasKey("DeactivatedUserId") + .HasName("user_deactivations_pkey"); + + b.HasIndex("DeactivatedByUserId"); + + b.ToTable("user_deactivations"); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.UserEmailChange", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("NewEmail") + .IsRequired() + .HasMaxLength(320) + .HasColumnType("character varying(320)") + .HasColumnName("email_new"); + + b.Property("OldEmail") + .IsRequired() + .HasMaxLength(320) + .HasColumnType("character varying(320)") + .HasColumnName("email_old"); + + b.Property("SecurityStampAtCreate") + .HasColumnType("uuid") + .HasColumnName("security_stamp_at_create"); + + b.Property("TokenHash") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("token_hash") + .UseCollation("C"); + + b.Property("UsedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("used_at"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("Id") + .HasName("user_email_changes_pkey"); + + b.HasIndex("CreatedAt"); + + b.HasIndex("UsedAt"); + + b.HasIndex("UserId"); + + b.ToTable("user_email_changes"); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.UserNameBlacklist", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("MatchType") + .HasColumnType("match_type_enum") + .HasColumnName("match_type"); + + b.Property("Value") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("value") + .UseCollation("ndcoll"); + + b.HasKey("Id") + .HasName("user_name_blacklist_pkey"); + + var index = b.HasIndex("Value") + .IsUnique(); + + NpgsqlIndexBuilderExtensions.UseCollation(index, new[] { "ndcoll" }); + + b.ToTable("user_name_blacklist"); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.UserNameChange", b => + { + var id = b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityAlwaysColumn(id); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("OldName") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("old_name"); + + b.HasKey("Id", "UserId") + .HasName("user_name_changes_pkey"); + + b.HasIndex("CreatedAt"); + + b.HasIndex("OldName"); + + b.HasIndex("UserId"); + + b.ToTable("user_name_changes"); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.UserOAuthConnection", b => + { + b.Property("ProviderKey") + .HasColumnType("text") + .HasColumnName("provider_key") + .UseCollation("C"); + + b.Property("ExternalId") + .HasColumnType("text") + .HasColumnName("external_id"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("DisplayName") + .HasColumnType("text") + .HasColumnName("display_name"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("ProviderKey", "ExternalId") + .HasName("user_oauth_connections_pkey"); + + b.HasIndex("UserId"); + + b.ToTable("user_oauth_connections"); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.UserPasswordReset", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("SecurityStampAtCreate") + .HasColumnType("uuid") + .HasColumnName("security_stamp_at_create"); + + b.Property("TokenHash") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("token_hash") + .UseCollation("C"); + + b.Property("UsedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("used_at"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("Id") + .HasName("user_password_resets_pkey"); + + b.HasIndex("UserId"); + + b.ToTable("user_password_resets"); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.UserShare", b => + { + b.Property("SharedWithUserId") + .HasColumnType("uuid") + .HasColumnName("shared_with_user_id"); + + b.Property("ShockerId") + .HasColumnType("uuid") + .HasColumnName("shocker_id"); + + b.Property("AllowLiveControl") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(true) + .HasColumnName("allow_livecontrol"); + + b.Property("AllowShock") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(true) + .HasColumnName("allow_shock"); + + b.Property("AllowSound") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(true) + .HasColumnName("allow_sound"); + + b.Property("AllowVibrate") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(true) + .HasColumnName("allow_vibrate"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("IsPaused") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("is_paused"); + + b.Property("MaxDuration") + .HasColumnType("integer") + .HasColumnName("max_duration"); + + b.Property("MaxIntensity") + .HasColumnType("smallint") + .HasColumnName("max_intensity"); + + b.HasKey("SharedWithUserId", "ShockerId") + .HasName("user_shares_pkey"); + + b.HasIndex("SharedWithUserId"); + + b.HasIndex("ShockerId"); + + b.ToTable("user_shares"); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.UserShareInvite", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("OwnerId") + .HasColumnType("uuid") + .HasColumnName("owner_id"); + + b.Property("RecipientUserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("Id") + .HasName("user_share_invites_pkey"); + + b.HasIndex("OwnerId"); + + b.HasIndex("RecipientUserId"); + + b.ToTable("user_share_invites"); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.UserShareInviteShocker", b => + { + b.Property("InviteId") + .HasColumnType("uuid") + .HasColumnName("invite_id"); + + b.Property("ShockerId") + .HasColumnType("uuid") + .HasColumnName("shocker_id"); + + b.Property("AllowLiveControl") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(true) + .HasColumnName("allow_livecontrol"); + + b.Property("AllowShock") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(true) + .HasColumnName("allow_shock"); + + b.Property("AllowSound") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(true) + .HasColumnName("allow_sound"); + + b.Property("AllowVibrate") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(true) + .HasColumnName("allow_vibrate"); + + b.Property("IsPaused") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("is_paused"); + + b.Property("MaxDuration") + .HasColumnType("integer") + .HasColumnName("max_duration"); + + b.Property("MaxIntensity") + .HasColumnType("smallint") + .HasColumnName("max_intensity"); + + b.HasKey("InviteId", "ShockerId") + .HasName("user_share_invite_shockers_pkey"); + + b.HasIndex("ShockerId"); + + b.ToTable("user_share_invite_shockers"); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.ApiToken", b => + { + b.HasOne("OpenShock.Common.OpenShockDb.User", "User") + .WithMany("ApiTokens") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_api_tokens_user_id"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.ApiTokenReport", b => + { + b.HasOne("OpenShock.Common.OpenShockDb.User", "ReportedByUser") + .WithMany("ReportedApiTokens") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_api_token_reports_reported_by_user_id"); + + b.Navigation("ReportedByUser"); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.ControlStat", b => + { + b.HasOne("OpenShock.Common.OpenShockDb.User", "Owner") + .WithMany("ControlStats") + .HasForeignKey("OwnerUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_control_stats_owner_user_id"); + + b.HasOne("OpenShock.Common.OpenShockDb.Shocker", "Shocker") + .WithMany("ControlStats") + .HasForeignKey("ShockerId") + .OnDelete(DeleteBehavior.SetNull) + .HasConstraintName("fk_control_stats_shocker_id"); + + b.Navigation("Owner"); + + b.Navigation("Shocker"); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.Device", b => + { + b.HasOne("OpenShock.Common.OpenShockDb.User", "Owner") + .WithMany("Devices") + .HasForeignKey("OwnerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_devices_owner_id"); + + b.Navigation("Owner"); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.DeviceOtaUpdate", b => + { + b.HasOne("OpenShock.Common.OpenShockDb.Device", "Device") + .WithMany("OtaUpdates") + .HasForeignKey("DeviceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_device_ota_updates_device_id"); + + b.Navigation("Device"); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.PublicShare", b => + { + b.HasOne("OpenShock.Common.OpenShockDb.User", "Owner") + .WithMany("OwnedPublicShares") + .HasForeignKey("OwnerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_public_shares_owner_id"); + + b.Navigation("Owner"); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.PublicShareShocker", b => + { + b.HasOne("OpenShock.Common.OpenShockDb.PublicShare", "PublicShare") + .WithMany("ShockerMappings") + .HasForeignKey("PublicShareId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_public_share_shockers_public_share_id"); + + b.HasOne("OpenShock.Common.OpenShockDb.Shocker", "Shocker") + .WithMany("PublicShareMappings") + .HasForeignKey("ShockerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_public_share_shockers_shocker_id"); + + b.Navigation("PublicShare"); + + b.Navigation("Shocker"); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.Shocker", b => + { + b.HasOne("OpenShock.Common.OpenShockDb.Device", "Device") + .WithMany("Shockers") + .HasForeignKey("DeviceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_shockers_device_id"); + + b.Navigation("Device"); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.ShockerControlLog", b => + { + b.HasOne("OpenShock.Common.OpenShockDb.User", "ControlledByUser") + .WithMany("ShockerControlLogs") + .HasForeignKey("ControlledByUserId") + .OnDelete(DeleteBehavior.Cascade) + .HasConstraintName("fk_shocker_control_logs_controlled_by_user_id"); + + b.HasOne("OpenShock.Common.OpenShockDb.Shocker", "Shocker") + .WithMany("ShockerControlLogs") + .HasForeignKey("ShockerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_shocker_control_logs_shocker_id"); + + b.Navigation("ControlledByUser"); + + b.Navigation("Shocker"); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.ShockerShareCode", b => + { + b.HasOne("OpenShock.Common.OpenShockDb.Shocker", "Shocker") + .WithMany("ShockerShareCodes") + .HasForeignKey("ShockerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_shocker_share_codes_shocker_id"); + + b.Navigation("Shocker"); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.User", b => + { + b.HasOne("OpenShock.Common.OpenShockDb.AutomationToken", "CreatedByAutomationToken") + .WithMany("CreatedUsers") + .HasForeignKey("CreatedByAutomationTokenId") + .OnDelete(DeleteBehavior.Restrict) + .HasConstraintName("fk_users_created_by_automation_token_id"); + + b.Navigation("CreatedByAutomationToken"); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.UserActivationRequest", b => + { + b.HasOne("OpenShock.Common.OpenShockDb.User", "User") + .WithOne("UserActivationRequest") + .HasForeignKey("OpenShock.Common.OpenShockDb.UserActivationRequest", "UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_user_activation_requests_user_id"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.UserAuditLog", b => + { + b.HasOne("OpenShock.Common.OpenShockDb.User", "Actor") + .WithMany("ActorAuditLogs") + .HasForeignKey("ActorId") + .OnDelete(DeleteBehavior.SetNull) + .HasConstraintName("fk_user_audit_logs_actor_id"); + + b.HasOne("OpenShock.Common.OpenShockDb.User", "User") + .WithMany("AuditLogs") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_user_audit_logs_user_id"); + + b.Navigation("Actor"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.UserDeactivation", b => + { + b.HasOne("OpenShock.Common.OpenShockDb.User", "DeactivatedByUser") + .WithMany() + .HasForeignKey("DeactivatedByUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_user_deactivations_deactivated_by_user_id"); + + b.HasOne("OpenShock.Common.OpenShockDb.User", "DeactivatedUser") + .WithOne("UserDeactivation") + .HasForeignKey("OpenShock.Common.OpenShockDb.UserDeactivation", "DeactivatedUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_user_deactivations_deactivated_user_id"); + + b.Navigation("DeactivatedByUser"); + + b.Navigation("DeactivatedUser"); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.UserEmailChange", b => + { + b.HasOne("OpenShock.Common.OpenShockDb.User", "User") + .WithMany("EmailChanges") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_user_email_changes_user_id"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.UserNameChange", b => + { + b.HasOne("OpenShock.Common.OpenShockDb.User", "User") + .WithMany("NameChanges") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_user_name_changes_user_id"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.UserOAuthConnection", b => + { + b.HasOne("OpenShock.Common.OpenShockDb.User", "User") + .WithMany("OAuthConnections") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_user_oauth_connections_user_id"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.UserPasswordReset", b => + { + b.HasOne("OpenShock.Common.OpenShockDb.User", "User") + .WithMany("PasswordResets") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_user_password_resets_user_id"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.UserShare", b => + { + b.HasOne("OpenShock.Common.OpenShockDb.User", "SharedWithUser") + .WithMany("IncomingUserShares") + .HasForeignKey("SharedWithUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_user_shares_shared_with_user_id"); + + b.HasOne("OpenShock.Common.OpenShockDb.Shocker", "Shocker") + .WithMany("UserShares") + .HasForeignKey("ShockerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_user_shares_shocker_id"); + + b.Navigation("SharedWithUser"); + + b.Navigation("Shocker"); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.UserShareInvite", b => + { + b.HasOne("OpenShock.Common.OpenShockDb.User", "Owner") + .WithMany("OutgoingUserShareInvites") + .HasForeignKey("OwnerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_user_share_invites_owner_id"); + + b.HasOne("OpenShock.Common.OpenShockDb.User", "RecipientUser") + .WithMany("IncomingUserShareInvites") + .HasForeignKey("RecipientUserId") + .OnDelete(DeleteBehavior.Cascade) + .HasConstraintName("fk_user_share_invites_recipient_user_id"); + + b.Navigation("Owner"); + + b.Navigation("RecipientUser"); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.UserShareInviteShocker", b => + { + b.HasOne("OpenShock.Common.OpenShockDb.UserShareInvite", "Invite") + .WithMany("ShockerMappings") + .HasForeignKey("InviteId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_user_share_invite_shockers_invite_id"); + + b.HasOne("OpenShock.Common.OpenShockDb.Shocker", "Shocker") + .WithMany("UserShareInviteShockerMappings") + .HasForeignKey("ShockerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_user_share_invite_shockers_shocker_id"); + + b.Navigation("Invite"); + + b.Navigation("Shocker"); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.AutomationToken", b => + { + b.Navigation("CreatedUsers"); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.Device", b => + { + b.Navigation("OtaUpdates"); + + b.Navigation("Shockers"); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.PublicShare", b => + { + b.Navigation("ShockerMappings"); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.Shocker", b => + { + b.Navigation("ControlStats"); + + b.Navigation("PublicShareMappings"); + + b.Navigation("ShockerControlLogs"); + + b.Navigation("ShockerShareCodes"); + + b.Navigation("UserShareInviteShockerMappings"); + + b.Navigation("UserShares"); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.User", b => + { + b.Navigation("ActorAuditLogs"); + + b.Navigation("ApiTokens"); + + b.Navigation("AuditLogs"); + + b.Navigation("ControlStats"); + + b.Navigation("Devices"); + + b.Navigation("EmailChanges"); + + b.Navigation("IncomingUserShareInvites"); + + b.Navigation("IncomingUserShares"); + + b.Navigation("NameChanges"); + + b.Navigation("OAuthConnections"); + + b.Navigation("OutgoingUserShareInvites"); + + b.Navigation("OwnedPublicShares"); + + b.Navigation("PasswordResets"); + + b.Navigation("ReportedApiTokens"); + + b.Navigation("ShockerControlLogs"); + + b.Navigation("UserActivationRequest"); + + b.Navigation("UserDeactivation"); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.UserShareInvite", b => + { + b.Navigation("ShockerMappings"); + }); +#pragma warning restore 612, 618 + } +} diff --git a/Common/Migrations/20261016142540_AddControlStats.cs b/Common/Migrations/20261016142540_AddControlStats.cs new file mode 100644 index 00000000..9858b38b --- /dev/null +++ b/Common/Migrations/20261016142540_AddControlStats.cs @@ -0,0 +1,214 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; +using OpenShock.Common.OpenShockDb; + +#nullable disable + +namespace OpenShock.Common.Migrations; + +/// +public partial class AddControlStats : Migration +{ + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AlterDatabase() + .Annotation("Npgsql:CollationDefinition:public.ndcoll", "und-u-ks-level2,und-u-ks-level2,icu,False") + .Annotation("Npgsql:Enum:audit_action", "login,logout,password_changed,email_change_requested,email_changed,username_changed,api_token_created,api_token_deleted,oauth_connected,oauth_disconnected,account_deactivated,account_reactivated,account_deleted,automation_token_created,automation_token_updated,automation_token_rotated,automation_token_deleted,automation_token_used") + .Annotation("Npgsql:Enum:automation_token_type", "turnstile,rate_limit,control_stats_read") + .Annotation("Npgsql:Enum:configuration_value_type", "string,bool,int,float,json") + .Annotation("Npgsql:Enum:control_limit_mode", "clamp,lerp") + .Annotation("Npgsql:Enum:control_type", "stop,shock,vibrate,sound") + .Annotation("Npgsql:Enum:email_status", "pending,sending,sent,failed,skipped") + .Annotation("Npgsql:Enum:email_type", "account_activation,password_reset,email_verification,email_change_notice") + .Annotation("Npgsql:Enum:match_type_enum", "exact,contains") + .Annotation("Npgsql:Enum:ota_update_status", "started,running,finished,error,timeout") + .Annotation("Npgsql:Enum:password_encryption_type", "bcrypt_enhanced,pbkdf2") + .Annotation("Npgsql:Enum:permission_type", "shockers.use,shockers.edit,shockers.pause,devices.edit,devices.auth,usershares.edit,usershares.pause,publicshares.pause,publicshares.edit") + .Annotation("Npgsql:Enum:role_type", "support,staff,admin,system") + .Annotation("Npgsql:Enum:shocker_model_type", "cai_xianlin,petrainer,petrainer_998dr,wellturn_t330") + .OldAnnotation("Npgsql:CollationDefinition:public.ndcoll", "und-u-ks-level2,und-u-ks-level2,icu,False") + .OldAnnotation("Npgsql:Enum:audit_action", "login,logout,password_changed,email_change_requested,email_changed,username_changed,api_token_created,api_token_deleted,oauth_connected,oauth_disconnected,account_deactivated,account_reactivated,account_deleted,automation_token_created,automation_token_updated,automation_token_rotated,automation_token_deleted,automation_token_used") + .OldAnnotation("Npgsql:Enum:automation_token_type", "turnstile,rate_limit") + .OldAnnotation("Npgsql:Enum:configuration_value_type", "string,bool,int,float,json") + .OldAnnotation("Npgsql:Enum:control_limit_mode", "clamp,lerp") + .OldAnnotation("Npgsql:Enum:control_type", "stop,shock,vibrate,sound") + .OldAnnotation("Npgsql:Enum:email_status", "pending,sending,sent,failed,skipped") + .OldAnnotation("Npgsql:Enum:email_type", "account_activation,password_reset,email_verification,email_change_notice") + .OldAnnotation("Npgsql:Enum:match_type_enum", "exact,contains") + .OldAnnotation("Npgsql:Enum:ota_update_status", "started,running,finished,error,timeout") + .OldAnnotation("Npgsql:Enum:password_encryption_type", "bcrypt_enhanced,pbkdf2") + .OldAnnotation("Npgsql:Enum:permission_type", "shockers.use,shockers.edit,shockers.pause,devices.edit,devices.auth,usershares.edit,usershares.pause,publicshares.pause,publicshares.edit") + .OldAnnotation("Npgsql:Enum:role_type", "support,staff,admin,system") + .OldAnnotation("Npgsql:Enum:shocker_model_type", "cai_xianlin,petrainer,petrainer_998dr,wellturn_t330"); + + migrationBuilder.CreateTable( + name: "control_stat_batches", + columns: table => new + { + id = table.Column(type: "uuid", nullable: false), + applied_at = table.Column(type: "timestamp with time zone", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("control_stat_batches_pkey", x => x.id); + }); + + migrationBuilder.CreateTable( + name: "control_stat_removals", + columns: table => new + { + user_id = table.Column(type: "uuid", nullable: false), + removed_at = table.Column(type: "timestamp with time zone", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("control_stat_removals_pkey", x => x.user_id); + }); + + migrationBuilder.CreateTable( + name: "control_stats", + columns: table => new + { + id = table.Column(type: "uuid", nullable: false), + owner_user_id = table.Column(type: "uuid", nullable: false), + shocker_id = table.Column(type: "uuid", nullable: true), + controller_user_id = table.Column(type: "uuid", nullable: true), + bucket = table.Column(type: "timestamp with time zone", nullable: false), + type = table.Column(type: "control_type", nullable: false), + live = table.Column(type: "boolean", nullable: false), + count = table.Column(type: "bigint", nullable: false), + duration_ms = table.Column(type: "bigint", nullable: false), + intensity_ms = table.Column(type: "bigint", nullable: false), + max_intensity = table.Column(type: "smallint", nullable: false), + updated_at = table.Column(type: "timestamp with time zone", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("control_stats_pkey", x => x.id); + table.ForeignKey( + name: "fk_control_stats_owner_user_id", + column: x => x.owner_user_id, + principalTable: "users", + principalColumn: "id", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "fk_control_stats_shocker_id", + column: x => x.shocker_id, + principalTable: "shockers", + principalColumn: "id", + onDelete: ReferentialAction.SetNull); + }); + + migrationBuilder.CreateIndex( + name: "IX_control_stat_batches_applied_at", + table: "control_stat_batches", + column: "applied_at"); + + migrationBuilder.CreateIndex( + name: "IX_control_stat_removals_removed_at", + table: "control_stat_removals", + column: "removed_at"); + + migrationBuilder.CreateIndex( + name: "IX_control_stats_controller_user_id_bucket", + table: "control_stats", + columns: new[] { "controller_user_id", "bucket" }); + + migrationBuilder.CreateIndex( + name: "IX_control_stats_owner_user_id_bucket", + table: "control_stats", + columns: new[] { "owner_user_id", "bucket" }); + + migrationBuilder.CreateIndex( + name: "IX_control_stats_shocker_id", + table: "control_stats", + column: "shocker_id"); + + migrationBuilder.CreateIndex( + name: "IX_control_stats_updated_at", + table: "control_stats", + column: "updated_at"); + + migrationBuilder.CreateIndex( + name: "ix_control_stats_upsert_key", + table: "control_stats", + columns: new[] { "owner_user_id", "shocker_id", "controller_user_id", "bucket", "type", "live" }, + unique: true, + filter: "shocker_id IS NOT NULL") + .Annotation("Npgsql:NullsDistinct", false); + + // Deleted stats rows (e.g. cascaded from a deleted owner) lower the totals of everyone involved without + // leaving a row behind, so record those users for change detection. Statement level, so a cascade of many + // rows is one insert. + migrationBuilder.Sql( + """ + CREATE FUNCTION control_stats_record_removals() RETURNS trigger LANGUAGE plpgsql AS $$ + BEGIN + INSERT INTO control_stat_removals (user_id, removed_at) + SELECT user_id, now() FROM ( + SELECT owner_user_id AS user_id FROM removed + UNION + SELECT controller_user_id FROM removed WHERE controller_user_id IS NOT NULL + ) users + ON CONFLICT (user_id) DO UPDATE SET removed_at = excluded.removed_at; + RETURN NULL; + END + $$; + + CREATE TRIGGER control_stats_record_removals + AFTER DELETE ON control_stats + REFERENCING OLD TABLE AS removed + FOR EACH STATEMENT EXECUTE FUNCTION control_stats_record_removals(); + """); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.Sql( + """ + DROP TRIGGER control_stats_record_removals ON control_stats; + DROP FUNCTION control_stats_record_removals(); + """); + + migrationBuilder.DropTable( + name: "control_stat_batches"); + + migrationBuilder.DropTable( + name: "control_stat_removals"); + + migrationBuilder.DropTable( + name: "control_stats"); + + migrationBuilder.AlterDatabase() + .Annotation("Npgsql:CollationDefinition:public.ndcoll", "und-u-ks-level2,und-u-ks-level2,icu,False") + .Annotation("Npgsql:Enum:audit_action", "login,logout,password_changed,email_change_requested,email_changed,username_changed,api_token_created,api_token_deleted,oauth_connected,oauth_disconnected,account_deactivated,account_reactivated,account_deleted,automation_token_created,automation_token_updated,automation_token_rotated,automation_token_deleted,automation_token_used") + .Annotation("Npgsql:Enum:automation_token_type", "turnstile,rate_limit") + .Annotation("Npgsql:Enum:configuration_value_type", "string,bool,int,float,json") + .Annotation("Npgsql:Enum:control_limit_mode", "clamp,lerp") + .Annotation("Npgsql:Enum:control_type", "stop,shock,vibrate,sound") + .Annotation("Npgsql:Enum:email_status", "pending,sending,sent,failed,skipped") + .Annotation("Npgsql:Enum:email_type", "account_activation,password_reset,email_verification,email_change_notice") + .Annotation("Npgsql:Enum:match_type_enum", "exact,contains") + .Annotation("Npgsql:Enum:ota_update_status", "started,running,finished,error,timeout") + .Annotation("Npgsql:Enum:password_encryption_type", "bcrypt_enhanced,pbkdf2") + .Annotation("Npgsql:Enum:permission_type", "shockers.use,shockers.edit,shockers.pause,devices.edit,devices.auth,usershares.edit,usershares.pause,publicshares.pause,publicshares.edit") + .Annotation("Npgsql:Enum:role_type", "support,staff,admin,system") + .Annotation("Npgsql:Enum:shocker_model_type", "cai_xianlin,petrainer,petrainer_998dr,wellturn_t330") + .OldAnnotation("Npgsql:CollationDefinition:public.ndcoll", "und-u-ks-level2,und-u-ks-level2,icu,False") + .OldAnnotation("Npgsql:Enum:audit_action", "login,logout,password_changed,email_change_requested,email_changed,username_changed,api_token_created,api_token_deleted,oauth_connected,oauth_disconnected,account_deactivated,account_reactivated,account_deleted,automation_token_created,automation_token_updated,automation_token_rotated,automation_token_deleted,automation_token_used") + .OldAnnotation("Npgsql:Enum:automation_token_type", "turnstile,rate_limit,control_stats_read") + .OldAnnotation("Npgsql:Enum:configuration_value_type", "string,bool,int,float,json") + .OldAnnotation("Npgsql:Enum:control_limit_mode", "clamp,lerp") + .OldAnnotation("Npgsql:Enum:control_type", "stop,shock,vibrate,sound") + .OldAnnotation("Npgsql:Enum:email_status", "pending,sending,sent,failed,skipped") + .OldAnnotation("Npgsql:Enum:email_type", "account_activation,password_reset,email_verification,email_change_notice") + .OldAnnotation("Npgsql:Enum:match_type_enum", "exact,contains") + .OldAnnotation("Npgsql:Enum:ota_update_status", "started,running,finished,error,timeout") + .OldAnnotation("Npgsql:Enum:password_encryption_type", "bcrypt_enhanced,pbkdf2") + .OldAnnotation("Npgsql:Enum:permission_type", "shockers.use,shockers.edit,shockers.pause,devices.edit,devices.auth,usershares.edit,usershares.pause,publicshares.pause,publicshares.edit") + .OldAnnotation("Npgsql:Enum:role_type", "support,staff,admin,system") + .OldAnnotation("Npgsql:Enum:shocker_model_type", "cai_xianlin,petrainer,petrainer_998dr,wellturn_t330"); + } +} diff --git a/Common/Migrations/OpenShockContextModelSnapshot.cs b/Common/Migrations/OpenShockContextModelSnapshot.cs index a6e656a0..296a8fb5 100644 --- a/Common/Migrations/OpenShockContextModelSnapshot.cs +++ b/Common/Migrations/OpenShockContextModelSnapshot.cs @@ -18,7 +18,7 @@ partial class OpenShockContextModelSnapshot : ModelSnapshot // If you encounter a merge conflict in the line below, it means you need to // discard one of the migration branches and recreate its migrations on top of // the other branch. See https://aka.ms/efcore-docs-migrations-conflicts for more info. - public override string LastMigrationId => "20260930204951_AddAutomationTokens"; + public override string LastMigrationId => "20261016142540_AddControlStats"; protected override void BuildModel(ModelBuilder modelBuilder) { @@ -29,7 +29,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) .HasAnnotation("Relational:MaxIdentifierLength", 63); NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "audit_action", new[] { "login", "logout", "password_changed", "email_change_requested", "email_changed", "username_changed", "api_token_created", "api_token_deleted", "oauth_connected", "oauth_disconnected", "account_deactivated", "account_reactivated", "account_deleted", "automation_token_created", "automation_token_updated", "automation_token_rotated", "automation_token_deleted", "automation_token_used" }); - NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "automation_token_type", new[] { "turnstile", "rate_limit" }); + NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "automation_token_type", new[] { "turnstile", "rate_limit", "control_stats_read" }); NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "configuration_value_type", new[] { "string", "bool", "int", "float", "json" }); NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "control_limit_mode", new[] { "clamp", "lerp" }); NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "control_type", new[] { "stop", "shock", "vibrate", "sound" }); @@ -391,6 +391,113 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.ToTable("configuration"); }); + modelBuilder.Entity("OpenShock.Common.OpenShockDb.ControlStat", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Bucket") + .HasColumnType("timestamp with time zone") + .HasColumnName("bucket"); + + b.Property("ControllerUserId") + .HasColumnType("uuid") + .HasColumnName("controller_user_id"); + + b.Property("Count") + .HasColumnType("bigint") + .HasColumnName("count"); + + b.Property("DurationMs") + .HasColumnType("bigint") + .HasColumnName("duration_ms"); + + b.Property("IntensityMs") + .HasColumnType("bigint") + .HasColumnName("intensity_ms"); + + b.Property("Live") + .HasColumnType("boolean") + .HasColumnName("live"); + + b.Property("MaxIntensity") + .HasColumnType("smallint") + .HasColumnName("max_intensity"); + + b.Property("OwnerUserId") + .HasColumnType("uuid") + .HasColumnName("owner_user_id"); + + b.Property("ShockerId") + .HasColumnType("uuid") + .HasColumnName("shocker_id"); + + b.Property("Type") + .HasColumnType("control_type") + .HasColumnName("type"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.HasKey("Id") + .HasName("control_stats_pkey"); + + b.HasIndex("ShockerId"); + + b.HasIndex("UpdatedAt"); + + b.HasIndex("ControllerUserId", "Bucket"); + + b.HasIndex("OwnerUserId", "Bucket"); + + var index = b.HasIndex("OwnerUserId", "ShockerId", "ControllerUserId", "Bucket", "Type", "Live") + .IsUnique() + .HasDatabaseName("ix_control_stats_upsert_key") + .HasFilter("shocker_id IS NOT NULL"); + + NpgsqlIndexBuilderExtensions.AreNullsDistinct(index, false); + + b.ToTable("control_stats"); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.ControlStatBatch", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AppliedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("applied_at"); + + b.HasKey("Id") + .HasName("control_stat_batches_pkey"); + + b.HasIndex("AppliedAt"); + + b.ToTable("control_stat_batches"); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.ControlStatRemoval", b => + { + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.Property("RemovedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("removed_at"); + + b.HasKey("UserId") + .HasName("control_stat_removals_pkey"); + + b.HasIndex("RemovedAt"); + + b.ToTable("control_stat_removals"); + }); + modelBuilder.Entity("OpenShock.Common.OpenShockDb.Device", b => { b.Property("Id") @@ -1420,6 +1527,26 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Navigation("ReportedByUser"); }); + modelBuilder.Entity("OpenShock.Common.OpenShockDb.ControlStat", b => + { + b.HasOne("OpenShock.Common.OpenShockDb.User", "Owner") + .WithMany("ControlStats") + .HasForeignKey("OwnerUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_control_stats_owner_user_id"); + + b.HasOne("OpenShock.Common.OpenShockDb.Shocker", "Shocker") + .WithMany("ControlStats") + .HasForeignKey("ShockerId") + .OnDelete(DeleteBehavior.SetNull) + .HasConstraintName("fk_control_stats_shocker_id"); + + b.Navigation("Owner"); + + b.Navigation("Shocker"); + }); + modelBuilder.Entity("OpenShock.Common.OpenShockDb.Device", b => { b.HasOne("OpenShock.Common.OpenShockDb.User", "Owner") @@ -1714,6 +1841,8 @@ protected override void BuildModel(ModelBuilder modelBuilder) modelBuilder.Entity("OpenShock.Common.OpenShockDb.Shocker", b => { + b.Navigation("ControlStats"); + b.Navigation("PublicShareMappings"); b.Navigation("ShockerControlLogs"); @@ -1733,6 +1862,8 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Navigation("AuditLogs"); + b.Navigation("ControlStats"); + b.Navigation("Devices"); b.Navigation("EmailChanges"); diff --git a/Common/OpenShockDb/Enums/AutomationTokenType.cs b/Common/OpenShockDb/Enums/AutomationTokenType.cs index 61cdd96f..b0f4fcda 100644 --- a/Common/OpenShockDb/Enums/AutomationTokenType.cs +++ b/Common/OpenShockDb/Enums/AutomationTokenType.cs @@ -4,11 +4,17 @@ namespace OpenShock.Common.OpenShockDb; /// -/// A protection that an automation token is allowed to switch off. +/// A protection that an automation token is allowed to switch off, or a service capability it grants. /// [PgEnum(Name = "automation_token_type")] public enum AutomationTokenType { [PgName("turnstile")] Turnstile = 0, [PgName("rate_limit")] RateLimit = 1, + + /// + /// Read control statistics of arbitrary users through the integration endpoints, used by first party + /// services like the Discord bot to keep profile widgets in sync. + /// + [PgName("control_stats_read")] ControlStatsRead = 2, } diff --git a/Common/OpenShockDb/Models/ControlStat.cs b/Common/OpenShockDb/Models/ControlStat.cs new file mode 100644 index 00000000..abfe424e --- /dev/null +++ b/Common/OpenShockDb/Models/ControlStat.cs @@ -0,0 +1,50 @@ +namespace OpenShock.Common.OpenShockDb; + +/// +/// Rollup of shocker control activity in 15 minute buckets. Unlike these rows are +/// never trimmed, so they back lifetime statistics. 15 minutes since every time zone's UTC offset is a multiple of it, +/// so local days always start on a bucket boundary (an hour would split them in e.g. Asia/Kolkata, UTC+5:30). One row +/// per owner, shocker, controller, bucket, type and live flag. Live control +/// activity is only counted once the gateway has written it to the hub's websocket. +/// +public sealed class ControlStat +{ + public required Guid Id { get; set; } + + /// The user who owns the shocker (the one being controlled). + public required Guid OwnerUserId { get; set; } + + /// Null once the shocker has been deleted. + public Guid? ShockerId { get; set; } + + /// + /// The user who sent the control. Null for public shares / guests. Deliberately not a foreign key, so stats + /// of deleted users keep their (now dangling) id instead of colliding with the guest bucket. + /// + public Guid? ControllerUserId { get; set; } + + /// Start of the 15 minute UTC bucket this row aggregates, see . + public required DateTime Bucket { get; set; } + + public required ControlType Type { get; set; } + + /// Whether this row aggregates live control (gateway) activity. + public required bool Live { get; set; } + + /// Number of controls, for live control the number of continuous bursts. + public required long Count { get; set; } + + public required long DurationMs { get; set; } + + /// Sum of intensity * duration in ms, divide by for the weighted average. + public required long IntensityMs { get; set; } + + public required byte MaxIntensity { get; set; } + + /// When this row was last added to, change detection for integrations keys off this. + public required DateTime UpdatedAt { get; set; } + + // Navigations + public User Owner { get; set; } = null!; + public Shocker? Shocker { get; set; } +} diff --git a/Common/OpenShockDb/Models/ControlStatBatch.cs b/Common/OpenShockDb/Models/ControlStatBatch.cs new file mode 100644 index 00000000..22952094 --- /dev/null +++ b/Common/OpenShockDb/Models/ControlStatBatch.cs @@ -0,0 +1,13 @@ +namespace OpenShock.Common.OpenShockDb; + +/// +/// Marks a buffered batch of deltas as applied, written in the same transaction as the +/// deltas. A batch retried after its commit went through but the acknowledgement got lost is then skipped instead of +/// being counted twice. +/// +public sealed class ControlStatBatch +{ + public required Guid Id { get; set; } + + public required DateTime AppliedAt { get; set; } +} diff --git a/Common/OpenShockDb/Models/ControlStatRemoval.cs b/Common/OpenShockDb/Models/ControlStatRemoval.cs new file mode 100644 index 00000000..600ab5db --- /dev/null +++ b/Common/OpenShockDb/Models/ControlStatRemoval.cs @@ -0,0 +1,14 @@ +namespace OpenShock.Common.OpenShockDb; + +/// +/// When rows involving a user, as owner or controller, were last deleted, e.g. by the owner +/// account being deleted. Deletions leave no row behind to carry an , so this is +/// how integrations learn that a user's totals went down. Written by a database trigger, one row per user. +/// +public sealed class ControlStatRemoval +{ + /// Deliberately not a foreign key, the user may be the one that got deleted. + public required Guid UserId { get; set; } + + public required DateTime RemovedAt { get; set; } +} diff --git a/Common/OpenShockDb/Models/Shocker.cs b/Common/OpenShockDb/Models/Shocker.cs index 906ee9c2..aa018509 100644 --- a/Common/OpenShockDb/Models/Shocker.cs +++ b/Common/OpenShockDb/Models/Shocker.cs @@ -20,6 +20,7 @@ public sealed class Shocker public Device Device { get; set; } = null!; public ICollection UserShareInviteShockerMappings { get; } = []; public ICollection ShockerControlLogs { get; } = []; + public ICollection ControlStats { get; } = []; public ICollection ShockerShareCodes { get; } = []; public ICollection UserShares { get; } = []; public ICollection PublicShareMappings { get; } = []; diff --git a/Common/OpenShockDb/Models/User.cs b/Common/OpenShockDb/Models/User.cs index ea267f86..c1e78c42 100644 --- a/Common/OpenShockDb/Models/User.cs +++ b/Common/OpenShockDb/Models/User.cs @@ -43,6 +43,7 @@ public sealed class User public ICollection IncomingUserShareInvites { get; } = []; public ICollection OwnedPublicShares { get; } = []; public ICollection ShockerControlLogs { get; } = []; + public ICollection ControlStats { get; } = []; public ICollection NameChanges { get; } = []; public ICollection EmailChanges { get; } = []; public ICollection PasswordResets { get; } = []; diff --git a/Common/OpenShockDb/OpenShockContext.cs b/Common/OpenShockDb/OpenShockContext.cs index ea03bab8..7191fef7 100644 --- a/Common/OpenShockDb/OpenShockContext.cs +++ b/Common/OpenShockDb/OpenShockContext.cs @@ -85,6 +85,12 @@ public static void ConfigureOptionsBuilder(DbContextOptionsBuilder optionsBuilde public DbSet ShockerControlLogs { get; set; } + public DbSet ControlStats { get; set; } + + public DbSet ControlStatBatches { get; set; } + + public DbSet ControlStatRemovals { get; set; } + public DbSet UserShares { get; set; } public DbSet ShockerShareCodes { get; set; } @@ -452,6 +458,79 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) .HasConstraintName("fk_shocker_control_logs_shocker_id"); }); + modelBuilder.Entity(entity => + { + entity.HasKey(e => e.Id).HasName("control_stats_pkey"); + + entity.ToTable("control_stats"); + + // Upsert target. Rows orphaned by a shocker delete (shocker_id SET NULL) fall outside the filter, so + // they may duplicate each other without violating it; readers sum them anyway. + entity.HasIndex(e => new { e.OwnerUserId, e.ShockerId, e.ControllerUserId, e.Bucket, e.Type, e.Live }) + .IsUnique() + .AreNullsDistinct(false) + .HasFilter("shocker_id IS NOT NULL") + .HasDatabaseName("ix_control_stats_upsert_key"); + entity.HasIndex(e => new { e.OwnerUserId, e.Bucket }); + entity.HasIndex(e => new { e.ControllerUserId, e.Bucket }); + entity.HasIndex(e => e.ShockerId); + entity.HasIndex(e => e.UpdatedAt); + + entity.Property(e => e.Id) + .ValueGeneratedNever() + .HasColumnName("id"); + entity.Property(e => e.OwnerUserId).HasColumnName("owner_user_id"); + entity.Property(e => e.ShockerId).HasColumnName("shocker_id"); + entity.Property(e => e.ControllerUserId).HasColumnName("controller_user_id"); + entity.Property(e => e.Bucket).HasColumnName("bucket"); + entity.Property(e => e.Type).HasColumnType("control_type").HasColumnName("type"); + entity.Property(e => e.Live).HasColumnName("live"); + entity.Property(e => e.Count).HasColumnName("count"); + entity.Property(e => e.DurationMs).HasColumnName("duration_ms"); + entity.Property(e => e.IntensityMs).HasColumnName("intensity_ms"); + entity.Property(e => e.MaxIntensity).HasColumnName("max_intensity"); + entity.Property(e => e.UpdatedAt).HasColumnName("updated_at"); + + entity.HasOne(e => e.Owner).WithMany(u => u.ControlStats) + .HasForeignKey(e => e.OwnerUserId) + .OnDelete(DeleteBehavior.Cascade) + .HasConstraintName("fk_control_stats_owner_user_id"); + + entity.HasOne(e => e.Shocker).WithMany(s => s.ControlStats) + .HasForeignKey(e => e.ShockerId) + .OnDelete(DeleteBehavior.SetNull) + .HasConstraintName("fk_control_stats_shocker_id"); + }); + + modelBuilder.Entity(entity => + { + entity.HasKey(e => e.Id).HasName("control_stat_batches_pkey"); + + entity.ToTable("control_stat_batches"); + + entity.HasIndex(e => e.AppliedAt); + + entity.Property(e => e.Id) + .ValueGeneratedNever() + .HasColumnName("id"); + entity.Property(e => e.AppliedAt).HasColumnName("applied_at"); + }); + + // Filled by the control_stats_record_removals trigger, see the AddControlStats migration + modelBuilder.Entity(entity => + { + entity.HasKey(e => e.UserId).HasName("control_stat_removals_pkey"); + + entity.ToTable("control_stat_removals"); + + entity.HasIndex(e => e.RemovedAt); + + entity.Property(e => e.UserId) + .ValueGeneratedNever() + .HasColumnName("user_id"); + entity.Property(e => e.RemovedAt).HasColumnName("removed_at"); + }); + modelBuilder.Entity(entity => { entity.HasKey(e => new { e.SharedWithUserId, e.ShockerId }).HasName("user_shares_pkey"); diff --git a/Common/OpenShockDb/Queries/ControlStatQueries.cs b/Common/OpenShockDb/Queries/ControlStatQueries.cs new file mode 100644 index 00000000..e2cf9ed5 --- /dev/null +++ b/Common/OpenShockDb/Queries/ControlStatQueries.cs @@ -0,0 +1,151 @@ +using Microsoft.EntityFrameworkCore; + +namespace OpenShock.Common.OpenShockDb; + +/// +/// A pending increment for one bucket. +/// +public readonly record struct ControlStatDelta( + Guid OwnerUserId, + Guid ShockerId, + Guid? ControllerUserId, + DateTime Bucket, + ControlType Type, + bool Live, + long Count, + long DurationMs, + long IntensityMs, + byte MaxIntensity) +{ + /// Granularity of , every time zone's UTC offset is a multiple of it. + public static readonly TimeSpan BucketSize = TimeSpan.FromMinutes(15); + + /// Truncates a UTC timestamp to the start of its bucket. + public static DateTime ToBucket(DateTime utc) => + new(utc.Ticks - utc.Ticks % BucketSize.Ticks, DateTimeKind.Utc); + + /// Combines two deltas for the same bucket. + public ControlStatDelta Merge(ControlStatDelta other) => this with + { + Count = Count + other.Count, + DurationMs = DurationMs + other.DurationMs, + IntensityMs = IntensityMs + other.IntensityMs, + MaxIntensity = Math.Max(MaxIntensity, other.MaxIntensity) + }; +} + +/// +/// Write path for the control_stats rollup table. +/// +public static class ControlStatQueries +{ + /// + /// How long after its creation a buffered batch may still be applied, older ones are refused. This is what makes + /// pruning markers safe: once no retry of a batch can be applied anymore, its marker + /// has nothing left to deduplicate. + /// + public static readonly TimeSpan BatchReplayLifetime = TimeSpan.FromDays(30); + + /// + /// Markers outlive by this much, covering clock skew between the services that + /// create batches and the database that stamps the markers. + /// + private static readonly TimeSpan BatchMarkerMargin = TimeSpan.FromDays(1); + + /// + /// Adds the given deltas onto their buckets in a single statement, creating buckets as needed. Deltas of shockers + /// deleted in the meantime are kept with no shocker, like rows orphaned by the delete. Deltas of owners that no + /// longer exist are dropped, their stats are gone with them. + /// + public static Task UpsertControlStatsAsync(this OpenShockContext db, IEnumerable deltas, + CancellationToken cancellationToken = default) + { + // One row per conflict key (ON CONFLICT can't touch a row twice in one statement), in a stable order so + // concurrent upserts lock rows in the same order and can't deadlock. + var merged = deltas + .Where(d => d.Type != ControlType.Stop) + .Select(d => d with { Bucket = ControlStatDelta.ToBucket(d.Bucket) }) + .GroupBy(d => (d.ShockerId, d.ControllerUserId, d.Bucket, d.Type, d.Live)) + .Select(g => g.Aggregate((a, b) => a.Merge(b))) + .OrderBy(d => d.ShockerId) + .ThenBy(d => d.ControllerUserId) + .ThenBy(d => d.Bucket) + .ThenBy(d => d.Type) + .ThenBy(d => d.Live) + .ToArray(); + + if (merged.Length == 0) return Task.FromResult(0); + + var ids = merged.Select(_ => Guid.CreateVersion7()).ToArray(); + var ownerIds = merged.Select(d => d.OwnerUserId).ToArray(); + var shockerIds = merged.Select(d => d.ShockerId).ToArray(); + var controllerIds = merged.Select(d => d.ControllerUserId).ToArray(); + var buckets = merged.Select(d => d.Bucket).ToArray(); + var types = merged.Select(d => d.Type).ToArray(); + var lives = merged.Select(d => d.Live).ToArray(); + var counts = merged.Select(d => d.Count).ToArray(); + var durations = merged.Select(d => d.DurationMs).ToArray(); + var intensityMs = merged.Select(d => d.IntensityMs).ToArray(); + var maxIntensities = merged.Select(d => (short)d.MaxIntensity).ToArray(); + + // A deleted shocker resolves to a null shocker_id, which falls outside the upsert key's filter and is + // inserted as its own row instead of violating the foreign key. + return db.Database.ExecuteSqlAsync( + $""" + INSERT INTO control_stats (id, owner_user_id, shocker_id, controller_user_id, bucket, type, live, count, duration_ms, intensity_ms, max_intensity, updated_at) + SELECT v.id, v.owner_user_id, s.id, v.controller_user_id, v.bucket, v.type, v.live, v.count, v.duration_ms, v.intensity_ms, v.max_intensity, now() + FROM unnest({ids}, {ownerIds}, {shockerIds}, {controllerIds}, {buckets}, {types}, {lives}, {counts}, {durations}, {intensityMs}, {maxIntensities}) + AS v(id, owner_user_id, shocker_id, controller_user_id, bucket, type, live, count, duration_ms, intensity_ms, max_intensity) + JOIN users u ON u.id = v.owner_user_id + LEFT JOIN shockers s ON s.id = v.shocker_id + ON CONFLICT (owner_user_id, shocker_id, controller_user_id, bucket, type, live) WHERE shocker_id IS NOT NULL + DO UPDATE SET + count = control_stats.count + excluded.count, + duration_ms = control_stats.duration_ms + excluded.duration_ms, + intensity_ms = control_stats.intensity_ms + excluded.intensity_ms, + max_intensity = GREATEST(control_stats.max_intensity, excluded.max_intensity), + updated_at = excluded.updated_at + """, cancellationToken); + } + + /// + /// Applies a buffered batch of deltas exactly once. The batch is marked applied in the same transaction, so + /// retrying it with the same after an unknown commit outcome can't count it twice. + /// Batches older than are refused, as their marker may have been pruned. + /// + public static async Task ApplyControlStatBatchAsync(this OpenShockContext db, Guid batchId, + DateTime createdAt, IEnumerable deltas, CancellationToken cancellationToken = default) + { + if (DateTime.UtcNow - createdAt > BatchReplayLifetime) return ControlStatBatchResult.Expired; + + await using var transaction = await db.Database.BeginTransactionAsync(cancellationToken); + + var marked = await db.Database.ExecuteSqlAsync( + $"INSERT INTO control_stat_batches (id, applied_at) VALUES ({batchId}, now()) ON CONFLICT DO NOTHING", + cancellationToken); + if (marked == 0) return ControlStatBatchResult.AlreadyApplied; + + await db.UpsertControlStatsAsync(deltas, cancellationToken); + + // Only markers of batches that can no longer be applied, see BatchReplayLifetime + var prunableBefore = DateTime.UtcNow - BatchReplayLifetime - BatchMarkerMargin; + await db.ControlStatBatches.Where(x => x.AppliedAt < prunableBefore).ExecuteDeleteAsync(cancellationToken); + + await transaction.CommitAsync(cancellationToken); + return ControlStatBatchResult.Applied; + } +} + +/// +/// Outcome of . +/// +public enum ControlStatBatchResult +{ + Applied, + + /// A previous attempt with the same batch id was committed. + AlreadyApplied, + + /// Older than , not applied. + Expired +} diff --git a/Common/OpenShockServiceHelper.cs b/Common/OpenShockServiceHelper.cs index 0bef97f4..42085b55 100644 --- a/Common/OpenShockServiceHelper.cs +++ b/Common/OpenShockServiceHelper.cs @@ -377,6 +377,7 @@ public static IServiceCollection AddOpenShockServices(this IServiceCollection se options.AddPolicy("auth", _ => RateLimitPartition.GetNoLimiter("test-nolimit")); options.AddPolicy("token-reporting", _ => RateLimitPartition.GetNoLimiter("test-nolimit")); options.AddPolicy("shocker-logs", _ => RateLimitPartition.GetNoLimiter("test-nolimit")); + options.AddPolicy("control-stats", _ => RateLimitPartition.GetNoLimiter("test-nolimit")); return; } @@ -485,6 +486,16 @@ await context.HttpContext.Response.WriteAsync("Too Many Requests. Please try aga QueueProcessingOrder = QueueProcessingOrder.OldestFirst, QueueLimit = 20 })); + + // Control stats aggregate a user's whole history, so cap how many run at once + options.AddPolicy("control-stats", context => + TryBypass(context) ?? + RateLimitPartition.GetConcurrencyLimiter("control-stats", _ => new ConcurrencyLimiterOptions + { + PermitLimit = 10, + QueueProcessingOrder = QueueProcessingOrder.OldestFirst, + QueueLimit = 20 + })); }); return services; diff --git a/Common/Services/BatchUpdate/BatchUpdateService.cs b/Common/Services/BatchUpdate/BatchUpdateService.cs index 4d4a4e89..cdf096ca 100644 --- a/Common/Services/BatchUpdate/BatchUpdateService.cs +++ b/Common/Services/BatchUpdate/BatchUpdateService.cs @@ -1,8 +1,11 @@ -using Microsoft.EntityFrameworkCore; +using System.Diagnostics; +using System.Text.Json; +using Microsoft.EntityFrameworkCore; using NRedisStack.RedisStackCommands; using OpenShock.Common.Constants; using OpenShock.Common.OpenShockDb; using OpenShock.Common.Redis; +using OpenShock.Common.Services.ControlStats; using OpenShock.Common.Utils; using StackExchange.Redis; @@ -71,6 +74,30 @@ public sealed class BatchUpdateService : BackgroundService, IBatchUpdateService private readonly ConcurrentUniqueBatchQueue _tokenLastUsed = new(); private readonly ConcurrentUniqueBatchQueue _sessionLastUsed = new(); private readonly ConcurrentCountingBatchQueue<(Guid Id, string TokenHash)> _automationTokenUses = new(); + private readonly ControlStatsAccumulator _controlStats = new(); + + /// + /// Control stat batches not yet applied, oldest first. Only touched by the flush loop, a batch keeps its id + /// across retries so it is applied at most once. + /// + private readonly Queue _controlStatBatches = new(); + + /// + /// Redis list of control stat batches an instance couldn't apply before shutting down, any instance applies them. + /// + private const string ParkedControlStatBatchesKey = "control-stats:parked-batches"; + + /// + /// How long shutdown keeps trying to hand unapplied control stats to the database or Redis, within the host's + /// default 30 second shutdown timeout. + /// + private static readonly TimeSpan ShutdownHandOffTimeout = TimeSpan.FromSeconds(20); + + /// + /// How much of shutdown waits on the database, so a stalled write still + /// leaves time to park control stats in Redis. + /// + private static readonly TimeSpan ShutdownDatabaseTimeout = TimeSpan.FromSeconds(15); public BatchUpdateService(IDbContextFactory dbFactory, ILogger logger, IConnectionMultiplexer connectionMultiplexer) { @@ -79,21 +106,21 @@ public BatchUpdateService(IDbContextFactory dbFactory, ILogger _connectionMultiplexer = connectionMultiplexer; } - private async Task UpdateTokens() + private async Task UpdateTokens(CancellationToken cancellationToken = default) { var keys = _tokenLastUsed.DequeueAll().Keys.ToArray(); // Skip if there is nothing if (keys.Length < 1) return; - - await using var db = await _dbFactory.CreateDbContextAsync(); + + await using var db = await _dbFactory.CreateDbContextAsync(cancellationToken); await db.ApiTokens.Where(x => keys.Contains(x.Id)) - .ExecuteUpdateAsync(x => x.SetProperty(y => y.LastUsed, DateTime.UtcNow)); + .ExecuteUpdateAsync(x => x.SetProperty(y => y.LastUsed, DateTime.UtcNow), cancellationToken); - await db.SaveChangesAsync(); + await db.SaveChangesAsync(cancellationToken); } - - private async Task UpdateAutomationTokens() + + private async Task UpdateAutomationTokens(CancellationToken cancellationToken = default) { var uses = _automationTokenUses.DequeueAll(); @@ -104,7 +131,7 @@ private async Task UpdateAutomationTokens() try { - await using var db = await _dbFactory.CreateDbContextAsync(); + await using var db = await _dbFactory.CreateDbContextAsync(cancellationToken); foreach (var ((automationTokenId, tokenHash), count) in uses.ToArray()) { // Matching the hash drops uses of a secret rotated away since, so they never count towards @@ -112,7 +139,7 @@ private async Task UpdateAutomationTokens() await db.AutomationTokens.Where(x => x.Id == automationTokenId && x.TokenHash == tokenHash) .ExecuteUpdateAsync(x => x .SetProperty(y => y.LastUsedAt, now) - .SetProperty(y => y.UseCount, y => y.UseCount + count)); + .SetProperty(y => y.UseCount, y => y.UseCount + count), cancellationToken); uses.Remove((automationTokenId, tokenHash)); } @@ -126,7 +153,140 @@ await db.AutomationTokens.Where(x => x.Id == automationTokenId && x.TokenHash == } } - private async Task UpdateSessions() + private async Task UpdateControlStats() + { + EnqueueRecordedControlStats(); + await ApplyParkedControlStatBatches(); + await ApplyQueuedControlStatBatches(); + } + + private void EnqueueRecordedControlStats() + { + var deltas = _controlStats.DequeueAll(); + if (deltas.Length > 0) + _controlStatBatches.Enqueue(new PendingControlStatBatch(Guid.CreateVersion7(), DateTime.UtcNow, deltas)); + } + + private async Task ApplyQueuedControlStatBatches(CancellationToken cancellationToken = default) + { + // A batch that throws or is cancelled stays queued with its id. If its commit actually went through, the + // retry finds the batch marker and skips it instead of counting it twice. + while (_controlStatBatches.TryPeek(out var batch)) + { + await ApplyControlStatBatch(batch, cancellationToken); + _controlStatBatches.Dequeue(); + } + } + + private async Task ApplyControlStatBatch(PendingControlStatBatch batch, CancellationToken cancellationToken = default) + { + await using var db = await _dbFactory.CreateDbContextAsync(cancellationToken); + var result = await db.ApplyControlStatBatchAsync(batch.Id, batch.CreatedAt, batch.Deltas, cancellationToken); + if (result == ControlStatBatchResult.Expired) + { + _logger.LogError("Dropped control stat batch {BatchId} from {CreatedAt}, it outlived the replay lifetime: {Batch}", + batch.Id, batch.CreatedAt, JsonSerializer.Serialize(batch)); + } + } + + /// + /// Applies batches an instance parked in Redis on shutdown. Each one is only removed from Redis once applied, so + /// a crash in between leaves it parked. Several instances may apply the same batch concurrently, its marker makes + /// sure only one of them counts it. + /// + private async Task ApplyParkedControlStatBatches() + { + var redis = _connectionMultiplexer.GetDatabase(); + + RedisValue[] parked; + try + { + parked = await redis.ListRangeAsync(ParkedControlStatBatchesKey, 0, 99); + } + catch (Exception e) + { + // Still parked, the next flush tries again + _logger.LogWarning(e, "Failed to read parked control stat batches"); + return; + } + + foreach (var value in parked) + { + PendingControlStatBatch? batch = null; + try + { + batch = JsonSerializer.Deserialize(value.ToString()); + } + catch (JsonException e) + { + // Can never be applied, so it must not block the others + _logger.LogError(e, "Discarding malformed parked control stat batch: {Value}", value.ToString()); + } + + if (batch is not null) await ApplyControlStatBatch(batch); + await redis.ListRemoveAsync(ParkedControlStatBatchesKey, value, 1); + } + } + + /// + /// Stats can't be rebuilt, so on shutdown whatever is unapplied keeps being offered to the database until + /// is cancelled, and to Redis for another instance, until one takes it. Only if + /// neither does within are the batches written to the log, the last place + /// they can be recovered from. + /// + private async Task HandOffControlStatsOnShutdown(CancellationToken databaseToken) + { + EnqueueRecordedControlStats(); + + var started = Stopwatch.GetTimestamp(); + while (_controlStatBatches.Count > 0) + { + if (!databaseToken.IsCancellationRequested) + { + try + { + await ApplyQueuedControlStatBatches(databaseToken); + return; + } + catch (Exception e) + { + _logger.LogWarning(e, "Failed to apply {Count} control stat batches on shutdown", _controlStatBatches.Count); + } + } + + var remaining = ShutdownHandOffTimeout - Stopwatch.GetElapsedTime(started); + if (remaining <= TimeSpan.Zero) break; + + try + { + var values = _controlStatBatches.Select(b => (RedisValue)JsonSerializer.Serialize(b)).ToArray(); + // Redis calls can't be cancelled, only stopped waiting on. A push landing after we gave up parks + // batches that are also applied or logged here, their markers keep them from being counted twice. + await _connectionMultiplexer.GetDatabase().ListRightPushAsync(ParkedControlStatBatchesKey, values) + .WaitAsync(remaining); + _controlStatBatches.Clear(); + _logger.LogWarning("Parked {Count} unapplied control stat batches in Redis for another instance", values.Length); + return; + } + catch (Exception e) + { + _logger.LogWarning(e, "Failed to park {Count} control stat batches in Redis on shutdown", _controlStatBatches.Count); + } + + remaining = ShutdownHandOffTimeout - Stopwatch.GetElapsedTime(started); + if (remaining <= TimeSpan.Zero) break; + await Task.Delay(remaining < TimeSpan.FromSeconds(1) ? remaining : TimeSpan.FromSeconds(1)); + } + + if (_controlStatBatches.Count == 0) return; + + _logger.LogCritical("Lost {Count} control stat batches on shutdown, neither the database nor Redis took them: {Batches}", + _controlStatBatches.Count, JsonSerializer.Serialize(_controlStatBatches)); + } + + private sealed record PendingControlStatBatch(Guid Id, DateTime CreatedAt, ControlStatDelta[] Deltas); + + private async Task UpdateSessions(CancellationToken cancellationToken = default) { var sessionsToUpdate = new List>(); @@ -139,7 +299,7 @@ private async Task UpdateSessions() try { - await Task.WhenAll(sessionsToUpdate); + await Task.WhenAll(sessionsToUpdate).WaitAsync(cancellationToken); } catch (Exception e) { _logger.LogTrace(e, "Error updating a sessions last used value"); @@ -156,6 +316,11 @@ public void UpdateAutomationTokenUsed(Guid automationTokenId, string tokenHash) _automationTokenUses.Increment((automationTokenId, tokenHash)); } + public void RecordControlStat(ControlStatDelta delta) + { + _controlStats.Add(delta); + } + public void UpdateSessionLastUsed(string sessionToken, DateTimeOffset lastUsed) { // Only hash new tokens, old ones are 64 chars long @@ -183,16 +348,22 @@ protected override async Task ExecuteAsync(CancellationToken stoppingToken) // Shutdown requested, fall through to the final flush } - // Drain what was queued since the last tick, otherwise every shutdown - // silently loses up to Interval worth of last used updates - await Flush(); + // Drain what was queued since the last tick, otherwise every shutdown silently loses up to Interval worth of + // last used updates. Control stats are handed off alongside instead of flushed, leaving parked batches to + // other instances, and nothing waits on the database past ShutdownDatabaseTimeout. + using var databaseCts = new CancellationTokenSource(ShutdownDatabaseTimeout); + await Task.WhenAll( + RunUpdates(UpdateTokens(databaseCts.Token), UpdateAutomationTokens(databaseCts.Token), UpdateSessions(databaseCts.Token)), + HandOffControlStatsOnShutdown(databaseCts.Token)); } - private async Task Flush() + private Task Flush() => RunUpdates(UpdateTokens(), UpdateAutomationTokens(), UpdateControlStats(), UpdateSessions()); + + private async Task RunUpdates(params Task[] updates) { try { - await Task.WhenAll(UpdateTokens(), UpdateAutomationTokens(), UpdateSessions()); + await Task.WhenAll(updates); } catch (Exception e) { diff --git a/Common/Services/BatchUpdate/IBatchUpdateService.cs b/Common/Services/BatchUpdate/IBatchUpdateService.cs index 4b5444da..96d60699 100644 --- a/Common/Services/BatchUpdate/IBatchUpdateService.cs +++ b/Common/Services/BatchUpdate/IBatchUpdateService.cs @@ -1,4 +1,6 @@ -namespace OpenShock.Common.Services.BatchUpdate; +using OpenShock.Common.OpenShockDb; + +namespace OpenShock.Common.Services.BatchUpdate; public interface IBatchUpdateService { @@ -13,5 +15,11 @@ public interface IBatchUpdateService /// /// Hash of the secret presented; the use is dropped if the token was rotated since public void UpdateAutomationTokenUsed(Guid automationTokenId, string tokenHash); + /// + /// Buffer control activity for the control_stats rollup, used by live control so the per-tick hot path + /// never touches the database + /// + /// + public void RecordControlStat(ControlStatDelta delta); public void UpdateSessionLastUsed(string sessionToken, DateTimeOffset lastUsed); } \ No newline at end of file diff --git a/Common/Services/ControlSender.cs b/Common/Services/ControlSender.cs index a73d9bb0..07968f7a 100644 --- a/Common/Services/ControlSender.cs +++ b/Common/Services/ControlSender.cs @@ -64,10 +64,10 @@ public async Task ControlByUser(IReadOnlyList con }) .ToArrayAsync(); - return await ControlInternal(controls, sender, hubClients, shockers, ControlMetrics.Source.User, tokenLimits); + return await ControlInternal(controls, sender, sender.Id, hubClients, shockers, ControlMetrics.Source.User, tokenLimits); } - public async Task ControlPublicShare(IReadOnlyList controls, ControlLogSender sender, IHubClients hubClients, Guid publicShareId) + public async Task ControlPublicShare(IReadOnlyList controls, ControlLogSender sender, Guid? controllerUserId, IHubClients hubClients, Guid publicShareId) { var publicShareShockers = await _db.PublicShareShockerMappings .AsNoTracking() @@ -93,7 +93,7 @@ public async Task ControlPublicShare(IReadOnlyList ControlInternal(IReadOnlyList controls, ControlLogSender sender, IHubClients hubClients, ControlShockerObj[] allowedShockers, string source, ApiTokenControlLimits? tokenLimits = null) + private async Task ControlInternal(IReadOnlyList controls, ControlLogSender sender, Guid? controllerUserId, IHubClients hubClients, ControlShockerObj[] allowedShockers, string source, ApiTokenControlLimits? tokenLimits = null) { var shockersById = allowedShockers.ToDictionary(s => s.ShockerId, s => s); @@ -113,6 +113,7 @@ private async Task ControlInternal(IReadOnlyList var messagesByDevice = new Dictionary>(); var logsByOwner = new Dictionary>(); + var statDeltas = new List(controls.Count); foreach (var control in controls.DistinctBy(x => x.Id)) { @@ -170,17 +171,34 @@ private async Task ControlInternal(IReadOnlyList { Id = Guid.CreateVersion7(), ShockerId = shocker.ShockerId, - ControlledByUserId = sender.Id == Guid.Empty ? null : sender.Id, + ControlledByUserId = controllerUserId, Intensity = control.Intensity, Duration = control.Duration, Type = control.Type, CustomName = sender.CustomName, CreatedAt = now }); + + statDeltas.Add(new ControlStatDelta( + OwnerUserId: shocker.OwnerId, + ShockerId: shocker.ShockerId, + ControllerUserId: controllerUserId, + Bucket: now, + Type: control.Type, + Live: false, + Count: 1, + DurationMs: control.Duration, + IntensityMs: (long)control.Intensity * control.Duration, + MaxIntensity: control.Intensity)); } - // Save all db changes before continuing - await _db.SaveChangesAsync(); + // Save all db changes before continuing, logs and stats commit together + await using (var transaction = await _db.Database.BeginTransactionAsync()) + { + await _db.SaveChangesAsync(); + await _db.UpsertControlStatsAsync(statDeltas); + await transaction.CommitAsync(); + } // Then send all network events await Task.WhenAll([ diff --git a/Common/Services/ControlStats/ControlStatsAccumulator.cs b/Common/Services/ControlStats/ControlStatsAccumulator.cs new file mode 100644 index 00000000..665dfe19 --- /dev/null +++ b/Common/Services/ControlStats/ControlStatsAccumulator.cs @@ -0,0 +1,39 @@ +using OpenShock.Common.OpenShockDb; + +namespace OpenShock.Common.Services.ControlStats; + +/// +/// Thread-safe in-memory buffer of s, merged per bucket so its size is bounded by the +/// number of distinct buckets touched rather than the number of recorded events. +/// +public sealed class ControlStatsAccumulator +{ + private readonly Lock _lock = new(); + private Dictionary<(Guid ShockerId, Guid? ControllerUserId, DateTime Bucket, ControlType Type, bool Live), ControlStatDelta> _pending = []; + + public void Add(ControlStatDelta delta) + { + if (delta.Type == ControlType.Stop) return; + + delta = delta with { Bucket = ControlStatDelta.ToBucket(delta.Bucket) }; + var key = (delta.ShockerId, delta.ControllerUserId, delta.Bucket, delta.Type, delta.Live); + + lock (_lock) + { + _pending[key] = _pending.TryGetValue(key, out var existing) ? existing.Merge(delta) : delta; + } + } + + public ControlStatDelta[] DequeueAll() + { + Dictionary<(Guid, Guid?, DateTime, ControlType, bool), ControlStatDelta> items; + lock (_lock) + { + if (_pending.Count == 0) return []; + items = _pending; + _pending = []; + } + + return items.Values.ToArray(); + } +} diff --git a/Common/Services/IControlSender.cs b/Common/Services/IControlSender.cs index e7301c55..07223502 100644 --- a/Common/Services/IControlSender.cs +++ b/Common/Services/IControlSender.cs @@ -10,7 +10,7 @@ public interface IControlSender { public Task ControlByUser(IReadOnlyList controls, ControlLogSender sender, IHubClients hubClients, ApiTokenControlLimits? tokenLimits = null); - public Task ControlPublicShare(IReadOnlyList controls, ControlLogSender sender, IHubClients hubClients, Guid publicShareId); + public Task ControlPublicShare(IReadOnlyList controls, ControlLogSender sender, Guid? controllerUserId, IHubClients hubClients, Guid publicShareId); } /// diff --git a/Common/Websocket/WebsockBaseController.cs b/Common/Websocket/WebsockBaseController.cs index 63e9b468..6d8fe2b6 100644 --- a/Common/Websocket/WebsockBaseController.cs +++ b/Common/Websocket/WebsockBaseController.cs @@ -40,7 +40,12 @@ public abstract class WebsocketBaseController : OpenShockControllerBase, IAsy /// /// Channel for multithreading thread safety of the websocket, MessageLoop is the only reader for this channel /// - protected readonly Channel Channel = System.Threading.Channels.Channel.CreateUnbounded(); + protected readonly Channel Channel = System.Threading.Channels.Channel.CreateUnbounded(); + + /// + /// A message waiting to be sent, is completed with whether it was written to the websocket + /// + protected readonly record struct QueuedMessage(T Data, TaskCompletionSource? Sent); #pragma warning disable IDISP008 protected WebSocket? WebSocket; @@ -59,15 +64,41 @@ protected WebsocketBaseController(ILogger> logger) [NonAction] public ValueTask QueueMessage(T data) { - if (WebSocket is null or { State: WebSocketState.Closed or WebSocketState.CloseSent or WebSocketState.Aborted }) + if (!CanSend()) { Logger.LogDebug("WebSocket is null, abort or closed, not sending message"); return ValueTask.CompletedTask; } - return Channel.Writer.WriteAsync(data, LinkedToken); + return Channel.Writer.WriteAsync(new QueuedMessage(data, null), LinkedToken); } + /// + /// Queue a message and wait until it has been written to the websocket + /// + /// + /// Whether the message was written, false if the websocket closed or failed before sending it + [NonAction] + public async Task SendMessage(T data) + { + if (!CanSend()) return false; + + var sent = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + try + { + await Channel.Writer.WriteAsync(new QueuedMessage(data, sent), LinkedToken); + } + catch (Exception e) when (e is ChannelClosedException or OperationCanceledException) + { + return false; + } + + return await sent.Task; + } + + private bool CanSend() => + WebSocket is not (null or { State: WebSocketState.Closed or WebSocketState.CloseSent or WebSocketState.Aborted }); + private bool _disposed; /// @@ -172,19 +203,30 @@ await WebSocket.CloseOutputAsync(WebSocketCloseStatus.NormalClosure, "Normal clo [NonAction] private async Task MessageLoop() { - await foreach (var msg in Channel.Reader.ReadAllAsync(LinkedToken)) + try { - try - { - await SendWebSocketMessage(msg, WebSocket!, LinkedToken); - } - catch (Exception e) + await foreach (var msg in Channel.Reader.ReadAllAsync(LinkedToken)) { - Logger.LogError(e, "Error while sending message to client - {Msg}", JsonSerializer.Serialize(msg)); - await ForceClose(WebSocketCloseStatus.InternalServerError, "Internal server error, error sending message to websocket client"); - throw; + try + { + await SendWebSocketMessage(msg.Data, WebSocket!, LinkedToken); + msg.Sent?.TrySetResult(true); + } + catch (Exception e) + { + msg.Sent?.TrySetResult(false); + Logger.LogError(e, "Error while sending message to client - {Msg}", JsonSerializer.Serialize(msg.Data)); + await ForceClose(WebSocketCloseStatus.InternalServerError, "Internal server error, error sending message to websocket client"); + throw; + } } } + finally + { + // Nothing is sent anymore, so close the channel to new writes and fail everyone still waiting on theirs + Channel.Writer.TryComplete(); + while (Channel.Reader.TryRead(out var unsent)) unsent.Sent?.TrySetResult(false); + } } /// diff --git a/LiveControlGateway/Controllers/HubControllerBase.cs b/LiveControlGateway/Controllers/HubControllerBase.cs index f38be002..2f3a944e 100644 --- a/LiveControlGateway/Controllers/HubControllerBase.cs +++ b/LiveControlGateway/Controllers/HubControllerBase.cs @@ -191,6 +191,10 @@ protected override async Task UnregisterConnection() [NonAction] public abstract ValueTask Control(IList controlCommands); + /// + [NonAction] + public abstract Task SendControl(IList controlCommands); + /// [NonAction] public abstract ValueTask CaptivePortal(bool enable); diff --git a/LiveControlGateway/Controllers/HubV1Controller.cs b/LiveControlGateway/Controllers/HubV1Controller.cs index 9d3a067f..74fe71e4 100644 --- a/LiveControlGateway/Controllers/HubV1Controller.cs +++ b/LiveControlGateway/Controllers/HubV1Controller.cs @@ -167,7 +167,15 @@ await otaService.Error(CurrentHubId, payload.BootStatus.OtaUpdateId, false, /// [NonAction] public override ValueTask Control(IList controlCommands) - => QueueMessage(new GatewayToHubMessage + => QueueMessage(ControlMessage(controlCommands)); + + /// + [NonAction] + public override Task SendControl(IList controlCommands) + => SendMessage(ControlMessage(controlCommands)); + + private static GatewayToHubMessage ControlMessage(IList controlCommands) + => new() { Payload = new GatewayToHubMessagePayload(new ShockerCommandList { @@ -180,7 +188,7 @@ public override ValueTask Control(IList controlCommands) Model = x.Model })] }) - }); + }; /// public override ValueTask EmergencyStop() diff --git a/LiveControlGateway/Controllers/HubV2Controller.cs b/LiveControlGateway/Controllers/HubV2Controller.cs index a7dc7d4d..0e65b819 100644 --- a/LiveControlGateway/Controllers/HubV2Controller.cs +++ b/LiveControlGateway/Controllers/HubV2Controller.cs @@ -197,13 +197,20 @@ await otaService.Error(CurrentHubId, payload.BootStatus.OtaUpdateId, false, /// public override ValueTask Control(IList controlCommands) - => QueueMessage(new GatewayToHubMessage + => QueueMessage(ControlMessage(controlCommands)); + + /// + public override Task SendControl(IList controlCommands) + => SendMessage(ControlMessage(controlCommands)); + + private static GatewayToHubMessage ControlMessage(IList controlCommands) + => new() { Payload = new GatewayToHubMessagePayload(new ShockerCommandList { Commands = controlCommands }) - }); + }; /// public override ValueTask CaptivePortal(bool enable) diff --git a/LiveControlGateway/Controllers/IHubController.cs b/LiveControlGateway/Controllers/IHubController.cs index ad98ba30..b858a60e 100644 --- a/LiveControlGateway/Controllers/IHubController.cs +++ b/LiveControlGateway/Controllers/IHubController.cs @@ -27,6 +27,13 @@ public interface IHubController : IAsyncDisposable /// public ValueTask Control(IList controlCommands); + /// + /// Control shockers and wait until the commands have been written to the hub's websocket + /// + /// + /// Whether the commands were written, false if the connection closed or failed first + public Task SendControl(IList controlCommands); + /// /// Turn the captive portal on or off /// diff --git a/LiveControlGateway/Controllers/LiveControlController.cs b/LiveControlGateway/Controllers/LiveControlController.cs index d5c8c866..44dea852 100644 --- a/LiveControlGateway/Controllers/LiveControlController.cs +++ b/LiveControlGateway/Controllers/LiveControlController.cs @@ -522,7 +522,7 @@ await QueueMessage(new LiveControlResponse var intensity = Math.Clamp(scopedIntensity, HardLimits.MinControlIntensity, perms.Intensity ?? HardLimits.MaxControlIntensity); - var result = HubLifetime.ReceiveFrame(frame.Shocker, frame.Type, intensity, _tps); + var result = HubLifetime.ReceiveFrame(frame.Shocker, _currentUser.Id, frame.Type, intensity, _tps); switch (result) { diff --git a/LiveControlGateway/LifetimeManager/HubLifetime.cs b/LiveControlGateway/LifetimeManager/HubLifetime.cs index 576d0f1e..2bed7997 100644 --- a/LiveControlGateway/LifetimeManager/HubLifetime.cs +++ b/LiveControlGateway/LifetimeManager/HubLifetime.cs @@ -7,6 +7,7 @@ using OpenShock.Common.Redis; using OpenShock.Common.Redis.PubSub; using OpenShock.Common.Results; +using OpenShock.Common.Services.BatchUpdate; using OpenShock.Common.Services.RedisPubSub; using OpenShock.Common.Utils; using OpenShock.LiveControlGateway.Controllers; @@ -50,6 +51,7 @@ public sealed class HubLifetime : IAsyncDisposable private readonly IDbContextFactory _dbContextFactory; private readonly IRedisConnectionProvider _redisConnectionProvider; private readonly IRedisPubService _redisPubService; + private readonly IBatchUpdateService _batchUpdateService; private readonly RedisChannel _deviceMsgChannel; private readonly ISubscriber _subscriber; @@ -83,12 +85,14 @@ public sealed class HubLifetime : IAsyncDisposable /// /// /// + /// /// public HubLifetime([Range(1, 10)] byte tps, IHubController hubController, IDbContextFactory dbContextFactory, IConnectionMultiplexer connectionMultiplexer, IRedisConnectionProvider redisConnectionProvider, IRedisPubService redisPubService, + IBatchUpdateService batchUpdateService, ILogger logger) { HubController = hubController; @@ -96,6 +100,7 @@ public HubLifetime([Range(1, 10)] byte tps, IHubController hubController, _dbContextFactory = dbContextFactory; _redisConnectionProvider = redisConnectionProvider; _redisPubService = redisPubService; + _batchUpdateService = batchUpdateService; _logger = logger; _waitBetweenTicks = TimeSpan.FromMilliseconds(1000.0 / tps); @@ -360,24 +365,53 @@ private async Task Update() { var now = DateTimeOffset.UtcNow; var commandList = new List(_shockerStates.Count); + var emittingFrames = new List<(ShockerState State, LiveFrame Frame)>(_shockerStates.Count); foreach (var state in _shockerStates.Values) { - if (state.ActiveUntil < now || state.ExclusiveUntil >= now) continue; + // Read once, the command and its stats are both built from this one frame even if another one arrives + var frame = state.LastFrame; + if (frame is null || frame.ActiveUntil < now || state.ExclusiveUntil >= now) + { + state.EmittingOnLastTick = null; + continue; + } + + emittingFrames.Add((state, frame)); commandList.Add(new ShockerCommand { Model = FbsMapper.ToFbsModelType(state.Model), Id = state.RfId, - Type = FbsMapper.ToFbsCommandType(state.LastType), - Intensity = state.LastIntensity, + Type = FbsMapper.ToFbsCommandType(frame.Type), + Intensity = frame.Intensity, Duration = _commandDuration, }); } if (commandList.Count == 0) return; - await HubController.Control(commandList); + // Only count what actually got written to the hub, a tick lost to a closing connection is not activity + if (!await HubController.SendControl(commandList)) return; + + var tickMs = (long)_waitBetweenTicks.TotalMilliseconds; + foreach (var (state, frame) in emittingFrames) + { + // Each sent tick is one tick interval of activity, a new type or controller starts a new burst + var emitting = (frame.Type, frame.ControllerId); + _batchUpdateService.RecordControlStat(new ControlStatDelta( + OwnerUserId: state.OwnerId, + ShockerId: state.Id, + ControllerUserId: frame.ControllerId, + Bucket: now.UtcDateTime, + Type: frame.Type, + Live: true, + Count: state.EmittingOnLastTick == emitting ? 0 : 1, + DurationMs: tickMs, + IntensityMs: frame.Intensity * tickMs, + MaxIntensity: frame.Intensity)); + state.EmittingOnLastTick = emitting; + } } /// @@ -402,6 +436,7 @@ private async Task UpdateShockers(OpenShockContext db, CancellationToken cancell _shockerStates = await db.Shockers.Where(x => x.DeviceId == HubController.Id).Select(x => new ShockerState() { Id = x.Id, + OwnerId = x.Device.OwnerId, Model = x.Model, RfId = x.RfId }).ToDictionaryAsync(x => x.Id, x => x, cancellationToken); @@ -411,12 +446,13 @@ private async Task UpdateShockers(OpenShockContext db, CancellationToken cancell /// Receive a control frame by a client, this implies that limits and permissions have been checked before /// /// + /// User sending the frame /// /// /// /// - public Union3 ReceiveFrame(Guid shocker, ControlType type, byte intensity, - byte tps) + public Union3 ReceiveFrame(Guid shocker, Guid controllerId, ControlType type, + byte intensity, byte tps) { if (!_shockerStates.TryGetValue(shocker, out var state)) return new NotFound(); if (state.ExclusiveUntil > DateTimeOffset.UtcNow) @@ -425,9 +461,7 @@ public Union3 ReceiveFrame(Guid shocker, Co Until = state.ExclusiveUntil }; - state.LastType = type; - state.LastIntensity = intensity; - state.ActiveUntil = CalculateActiveUntil(tps); + state.LastFrame = new LiveFrame(type, intensity, controllerId, CalculateActiveUntil(tps)); return new Success(); } diff --git a/LiveControlGateway/LifetimeManager/HubLifetimeManager.cs b/LiveControlGateway/LifetimeManager/HubLifetimeManager.cs index 614d6e53..6df4b402 100644 --- a/LiveControlGateway/LifetimeManager/HubLifetimeManager.cs +++ b/LiveControlGateway/LifetimeManager/HubLifetimeManager.cs @@ -6,6 +6,7 @@ using OpenShock.Common.OpenShockDb; using OpenShock.Common.Redis.PubSub; using OpenShock.Common.Results; +using OpenShock.Common.Services.BatchUpdate; using OpenShock.Common.Services.RedisPubSub; using OpenShock.LiveControlGateway.Controllers; using OpenShock.LiveControlGateway.Metrics; @@ -28,6 +29,7 @@ public sealed class HubLifetimeManager private readonly IRedisPubService _redisPubService; private readonly ILoggerFactory _loggerFactory; private readonly GatewayMetrics _metrics; + private readonly IBatchUpdateService _batchUpdateService; private readonly ILogger _logger; /// @@ -49,6 +51,7 @@ public sealed class HubLifetimeManager /// /// /// + /// /// public HubLifetimeManager( IDbContextFactory dbContextFactory, @@ -58,6 +61,7 @@ public HubLifetimeManager( ILoggerFactory loggerFactory, LcgOptions lcgOptions, GatewayMetrics metrics, + IBatchUpdateService batchUpdateService, [FromKeyedServices("OpenShock.Gateway.Meter")] Meter meter ) { @@ -67,6 +71,7 @@ public HubLifetimeManager( _redisPubService = redisPubService; _loggerFactory = loggerFactory; _metrics = metrics; + _batchUpdateService = batchUpdateService; _logger = _loggerFactory.CreateLogger(); @@ -201,6 +206,7 @@ private HubLifetime CreateNewLifetime(byte tps, IHubController hubController) _connectionMultiplexer, _redisConnectionProvider, _redisPubService, + _batchUpdateService, _loggerFactory.CreateLogger()); return deviceLifetime; diff --git a/LiveControlGateway/LifetimeManager/ShockerState.cs b/LiveControlGateway/LifetimeManager/ShockerState.cs index 7d8e0bd7..e726ff23 100644 --- a/LiveControlGateway/LifetimeManager/ShockerState.cs +++ b/LiveControlGateway/LifetimeManager/ShockerState.cs @@ -18,6 +18,11 @@ public sealed class ShockerState /// public required Guid Id { get; init; } + /// + /// User owning the shocker + /// + public required Guid OwnerId { get; init; } + /// /// RfId of the shocker /// @@ -31,23 +36,32 @@ public sealed class ShockerState // Last state /// - /// Last intensity sent to the shocker via live control - /// - [Range(HardLimits.MinControlIntensity, HardLimits.MaxControlIntensity)] - public byte LastIntensity { get; set; } = 0; - - /// - /// Last duration sent to the shocker via live control + /// Last live control frame received for the shocker. Replaced as a whole on every frame, so a reader always + /// sees one frame's type, intensity, controller and expiry together, never a mix of two frames. /// - public ControlType LastType { get; set; } = ControlType.Stop; - + public LiveFrame? LastFrame { get; set; } + /// - /// Active until time for the shocker, determined by client TPS interval + current time + /// Whether the shocker was emitting live control on the previous tick, with which type and by whom. + /// Used to count continuous bursts for stats. /// - public DateTimeOffset ActiveUntil = DateTimeOffset.MinValue; + public (ControlType Type, Guid ControllerId)? EmittingOnLastTick { get; set; } /// /// When an exclusive command is sent to the shocker, this is the time it will be exclusive until so we dont allow other live commands /// public DateTimeOffset ExclusiveUntil = DateTimeOffset.MinValue; -} \ No newline at end of file +} + +/// +/// A live control frame as received from a client +/// +/// Control type +/// Intensity, already clamped to the controller's permissions +/// User who sent the frame +/// Until when the frame keeps the shocker going, determined by client TPS interval + receive time +public sealed record LiveFrame( + ControlType Type, + [property: Range(HardLimits.MinControlIntensity, HardLimits.MaxControlIntensity)] byte Intensity, + Guid ControllerId, + DateTimeOffset ActiveUntil);