From c13e697cccbd7c2385e1766395b7fe56290e82ad Mon Sep 17 00:00:00 2001 From: LucHeart Date: Sun, 11 Oct 2026 17:49:05 +0200 Subject: [PATCH 1/6] feat: control stats rollup and self stats endpoint --- .../Tests/ControlStatsTests.cs | 223 ++ API/Controller/Users/GetSelfStats.cs | 194 ++ API/Controller/Users/_ApiController.cs | 5 +- API/Errors/ControlStatsError.cs | 12 + API/Models/Response/ControlStatsResponse.cs | 70 + .../Services/ControlStatsAccumulatorTests.cs | 51 + ...20261010115002_AddControlStats.Designer.cs | 1856 +++++++++++++++++ .../20261010115002_AddControlStats.cs | 78 + .../OpenShockContextModelSnapshot.cs | 91 +- Common/OpenShockDb/Models/ControlStat.cs | 44 + Common/OpenShockDb/Models/Shocker.cs | 1 + Common/OpenShockDb/Models/User.cs | 1 + Common/OpenShockDb/OpenShockContext.cs | 44 + .../OpenShockDb/Queries/ControlStatQueries.cs | 88 + Common/Services/ControlSender.cs | 21 +- .../ControlStats/ControlStatsAccumulator.cs | 39 + .../Controllers/LiveControlController.cs | 2 +- .../LifetimeManager/HubLifetime.cs | 31 +- .../LifetimeManager/HubLifetimeManager.cs | 5 + .../LifetimeManager/ShockerState.cs | 11 + LiveControlGateway/LiveControlStatsService.cs | 73 + LiveControlGateway/Program.cs | 4 +- 22 files changed, 2935 insertions(+), 9 deletions(-) create mode 100644 API.IntegrationTests/Tests/ControlStatsTests.cs create mode 100644 API/Controller/Users/GetSelfStats.cs create mode 100644 API/Errors/ControlStatsError.cs create mode 100644 API/Models/Response/ControlStatsResponse.cs create mode 100644 Common.Tests/Services/ControlStatsAccumulatorTests.cs create mode 100644 Common/Migrations/20261010115002_AddControlStats.Designer.cs create mode 100644 Common/Migrations/20261010115002_AddControlStats.cs create mode 100644 Common/OpenShockDb/Models/ControlStat.cs create mode 100644 Common/OpenShockDb/Queries/ControlStatQueries.cs create mode 100644 Common/Services/ControlStats/ControlStatsAccumulator.cs create mode 100644 LiveControlGateway/LiveControlStatsService.cs diff --git a/API.IntegrationTests/Tests/ControlStatsTests.cs b/API.IntegrationTests/Tests/ControlStatsTests.cs new file mode 100644 index 00000000..bda377a6 --- /dev/null +++ b/API.IntegrationTests/Tests/ControlStatsTests.cs @@ -0,0 +1,223 @@ +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(shockerA, user.Id, bucket, count: 2), + Delta(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 hour, same key twice in one batch, plus a guest (null controller) bucket + await db.UpsertControlStatsAsync([ + Delta(shockerId, userId, bucket.AddMinutes(5), count: 1, intensity: 20), + Delta(shockerId, userId, bucket.AddMinutes(50), count: 1, intensity: 80), + Delta(shockerId, null, bucket, count: 4) + ]); + await db.UpsertControlStatsAsync([Delta(shockerId, userId, bucket, count: 1, intensity: 10)]); + await db.UpsertControlStatsAsync([Delta(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 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(shockerId, user.Id, new DateTime(2026, 1, 1, 23, 0, 0, DateTimeKind.Utc), count: 1), + Delta(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_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/stats?tz=Not/AZone"); + await Assert.That(badTz.StatusCode).IsEqualTo(HttpStatusCode.BadRequest); + + var badRange = await client.GetAsync("/1/users/self/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/stats"); + await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.Unauthorized); + } + + private static ControlStatDelta Delta(Guid shockerId, Guid? controllerId, DateTime at, long count, byte intensity = 50) => + new(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/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/Controller/Users/GetSelfStats.cs b/API/Controller/Users/GetSelfStats.cs new file mode 100644 index 00000000..ef58f5e5 --- /dev/null +++ b/API/Controller/Users/GetSelfStats.cs @@ -0,0 +1,194 @@ +using System.ComponentModel.DataAnnotations; +using System.Net.Mime; +using Microsoft.AspNetCore.Mvc; +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, 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/stats")] + [ProducesResponseType(StatusCodes.Status200OK, MediaTypeNames.Application.Json)] + [ProducesResponseType(StatusCodes.Status400BadRequest, MediaTypeNames.Application.ProblemJson)] // InvalidTimeZone, InvalidRange + public async Task GetSelfStats( + [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. Done upfront since it + // only evaluates AT TIME ZONE once there are rows to apply it to. + try + { + await _db.Database.SqlQuery($"SELECT now() AT TIME ZONE {tz} AS \"Value\"").SingleAsync(cancellationToken); + } + catch (PostgresException e) when (e.SqlState == PostgresErrorCodes.InvalidParameterValue) + { + return Problem(ControlStatsError.InvalidTimeZone); + } + + var fromLocal = from?.ToDateTime(TimeOnly.MinValue); + var toLocalExclusive = to?.AddDays(1).ToDateTime(TimeOnly.MinValue); + + var stats = _db.ControlStats.AsQueryable(); + if (fromLocal is not null) stats = stats.Where(x => TimeZoneInfo.ConvertTimeBySystemTimeZoneId(x.Bucket, tz) >= fromLocal); + if (toLocalExclusive is not null) stats = stats.Where(x => TimeZoneInfo.ConvertTimeBySystemTimeZoneId(x.Bucket, tz) < toLocalExclusive); + + // 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 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..a05bfb83 --- /dev/null +++ b/API/Errors/ControlStatsError.cs @@ -0,0 +1,12 @@ +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 InvalidRange => new("ControlStats.InvalidRange", "The from date must not be after the to date", HttpStatusCode.BadRequest); +} diff --git a/API/Models/Response/ControlStatsResponse.cs b/API/Models/Response/ControlStatsResponse.cs new file mode 100644 index 00000000..21fd2802 --- /dev/null +++ b/API/Models/Response/ControlStatsResponse.cs @@ -0,0 +1,70 @@ +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; } +} diff --git a/Common.Tests/Services/ControlStatsAccumulatorTests.cs b/Common.Tests/Services/ControlStatsAccumulatorTests.cs new file mode 100644 index 00000000..9156d6a8 --- /dev/null +++ b/Common.Tests/Services/ControlStatsAccumulatorTests.cs @@ -0,0 +1,51 @@ +using OpenShock.Common.OpenShockDb; +using OpenShock.Common.Services.ControlStats; + +namespace OpenShock.Common.Tests.Services; + +public class ControlStatsAccumulatorTests +{ + 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(Shocker, Controller, at, type, Live: true, count, DurationMs: 100, IntensityMs: intensity * 100L, intensity); + + [Test] + public async Task Add_MergesWithinTheSameHour() + { + var accumulator = new ControlStatsAccumulator(); + accumulator.Add(Delta(new DateTime(2026, 1, 1, 10, 5, 0, DateTimeKind.Utc), count: 1, intensity: 20)); + accumulator.Add(Delta(new DateTime(2026, 1, 1, 10, 55, 0, 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, 0, 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_SplitsAcrossHoursAndTypes_AndIgnoresStop() + { + var accumulator = new ControlStatsAccumulator(); + accumulator.Add(Delta(new DateTime(2026, 1, 1, 10, 59, 0, DateTimeKind.Utc))); + accumulator.Add(Delta(new DateTime(2026, 1, 1, 11, 0, 0, DateTimeKind.Utc))); + accumulator.Add(Delta(new DateTime(2026, 1, 1, 11, 0, 0, DateTimeKind.Utc), ControlType.Vibrate)); + accumulator.Add(Delta(new DateTime(2026, 1, 1, 11, 0, 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/Migrations/20261010115002_AddControlStats.Designer.cs b/Common/Migrations/20261010115002_AddControlStats.Designer.cs new file mode 100644 index 00000000..24c924ae --- /dev/null +++ b/Common/Migrations/20261010115002_AddControlStats.Designer.cs @@ -0,0 +1,1856 @@ +// +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("20261010115002_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" }); + 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.HasKey("Id") + .HasName("control_stats_pkey"); + + b.HasIndex("ShockerId"); + + 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.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/20261010115002_AddControlStats.cs b/Common/Migrations/20261010115002_AddControlStats.cs new file mode 100644 index 00000000..5853ea22 --- /dev/null +++ b/Common/Migrations/20261010115002_AddControlStats.cs @@ -0,0 +1,78 @@ +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.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) + }, + 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_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_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); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "control_stats"); + } +} diff --git a/Common/Migrations/OpenShockContextModelSnapshot.cs b/Common/Migrations/OpenShockContextModelSnapshot.cs index a6e656a0..63e51257 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 => "20261010115002_AddControlStats"; protected override void BuildModel(ModelBuilder modelBuilder) { @@ -391,6 +391,71 @@ 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.HasKey("Id") + .HasName("control_stats_pkey"); + + b.HasIndex("ShockerId"); + + 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.Device", b => { b.Property("Id") @@ -1420,6 +1485,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 +1799,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 +1820,8 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Navigation("AuditLogs"); + b.Navigation("ControlStats"); + b.Navigation("Devices"); b.Navigation("EmailChanges"); diff --git a/Common/OpenShockDb/Models/ControlStat.cs b/Common/OpenShockDb/Models/ControlStat.cs new file mode 100644 index 00000000..b8df7f20 --- /dev/null +++ b/Common/OpenShockDb/Models/ControlStat.cs @@ -0,0 +1,44 @@ +namespace OpenShock.Common.OpenShockDb; + +/// +/// Hourly rollup of shocker control activity. Unlike these rows are never trimmed, +/// so they back lifetime statistics. One row per owner, shocker, controller, hour, type and live flag. +/// +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 UTC hour this row aggregates. + 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; } + + // Navigations + public User Owner { get; set; } = null!; + public Shocker? Shocker { 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..c5b78127 100644 --- a/Common/OpenShockDb/OpenShockContext.cs +++ b/Common/OpenShockDb/OpenShockContext.cs @@ -85,6 +85,8 @@ public static void ConfigureOptionsBuilder(DbContextOptionsBuilder optionsBuilde public DbSet ShockerControlLogs { get; set; } + public DbSet ControlStats { get; set; } + public DbSet UserShares { get; set; } public DbSet ShockerShareCodes { get; set; } @@ -452,6 +454,48 @@ 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.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.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 => 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..440fac12 --- /dev/null +++ b/Common/OpenShockDb/Queries/ControlStatQueries.cs @@ -0,0 +1,88 @@ +using Microsoft.EntityFrameworkCore; + +namespace OpenShock.Common.OpenShockDb; + +/// +/// A pending increment for one bucket. +/// +public readonly record struct ControlStatDelta( + Guid ShockerId, + Guid? ControllerUserId, + DateTime Bucket, + ControlType Type, + bool Live, + long Count, + long DurationMs, + long IntensityMs, + byte MaxIntensity) +{ + /// Truncates a UTC timestamp to the start of its hour, the granularity of . + public static DateTime ToBucket(DateTime utc) => + new(utc.Ticks - utc.Ticks % TimeSpan.TicksPerHour, 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 +{ + /// + /// Adds the given deltas onto their buckets in a single statement, creating buckets as needed. The owner is + /// resolved through shocker -> device, deltas for shockers that no longer exist are dropped. + /// + 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 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(); + + 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) + SELECT v.id, d.owner_id, v.shocker_id, v.controller_user_id, v.bucket, v.type, v.live, v.count, v.duration_ms, v.intensity_ms, v.max_intensity + FROM unnest({ids}, {shockerIds}, {controllerIds}, {buckets}, {types}, {lives}, {counts}, {durations}, {intensityMs}, {maxIntensities}) + AS v(id, shocker_id, controller_user_id, bucket, type, live, count, duration_ms, intensity_ms, max_intensity) + JOIN shockers s ON s.id = v.shocker_id + JOIN devices d ON d.id = s.device_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) + """, cancellationToken); + } +} diff --git a/Common/Services/ControlSender.cs b/Common/Services/ControlSender.cs index a73d9bb0..c0599f29 100644 --- a/Common/Services/ControlSender.cs +++ b/Common/Services/ControlSender.cs @@ -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)) { @@ -177,10 +178,26 @@ private async Task ControlInternal(IReadOnlyList CustomName = sender.CustomName, CreatedAt = now }); + + statDeltas.Add(new ControlStatDelta( + ShockerId: shocker.ShockerId, + ControllerUserId: sender.Id == Guid.Empty ? null : sender.Id, + 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/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..1f42567c 100644 --- a/LiveControlGateway/LifetimeManager/HubLifetime.cs +++ b/LiveControlGateway/LifetimeManager/HubLifetime.cs @@ -50,6 +50,7 @@ public sealed class HubLifetime : IAsyncDisposable private readonly IDbContextFactory _dbContextFactory; private readonly IRedisConnectionProvider _redisConnectionProvider; private readonly IRedisPubService _redisPubService; + private readonly LiveControlStatsService _liveControlStats; private readonly RedisChannel _deviceMsgChannel; private readonly ISubscriber _subscriber; @@ -83,12 +84,14 @@ public sealed class HubLifetime : IAsyncDisposable /// /// /// + /// /// public HubLifetime([Range(1, 10)] byte tps, IHubController hubController, IDbContextFactory dbContextFactory, IConnectionMultiplexer connectionMultiplexer, IRedisConnectionProvider redisConnectionProvider, IRedisPubService redisPubService, + LiveControlStatsService liveControlStats, ILogger logger) { HubController = hubController; @@ -96,6 +99,7 @@ public HubLifetime([Range(1, 10)] byte tps, IHubController hubController, _dbContextFactory = dbContextFactory; _redisConnectionProvider = redisConnectionProvider; _redisPubService = redisPubService; + _liveControlStats = liveControlStats; _logger = logger; _waitBetweenTicks = TimeSpan.FromMilliseconds(1000.0 / tps); @@ -360,10 +364,29 @@ private async Task Update() { var now = DateTimeOffset.UtcNow; var commandList = new List(_shockerStates.Count); + var tickMs = (long)_waitBetweenTicks.TotalMilliseconds; foreach (var state in _shockerStates.Values) { - if (state.ActiveUntil < now || state.ExclusiveUntil >= now) continue; + if (state.ActiveUntil < now || state.ExclusiveUntil >= now) + { + state.EmittingOnLastTick = null; + continue; + } + + // Each emitting tick is one tick interval of activity, a new type or controller starts a new burst + var emitting = (state.LastType, state.LastControllerId); + _liveControlStats.Record(new ControlStatDelta( + ShockerId: state.Id, + ControllerUserId: state.LastControllerId, + Bucket: now.UtcDateTime, + Type: state.LastType, + Live: true, + Count: state.EmittingOnLastTick == emitting ? 0 : 1, + DurationMs: tickMs, + IntensityMs: state.LastIntensity * tickMs, + MaxIntensity: state.LastIntensity)); + state.EmittingOnLastTick = emitting; commandList.Add(new ShockerCommand { @@ -411,12 +434,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) @@ -427,6 +451,7 @@ public Union3 ReceiveFrame(Guid shocker, Co state.LastType = type; state.LastIntensity = intensity; + state.LastControllerId = controllerId; state.ActiveUntil = CalculateActiveUntil(tps); return new Success(); } diff --git a/LiveControlGateway/LifetimeManager/HubLifetimeManager.cs b/LiveControlGateway/LifetimeManager/HubLifetimeManager.cs index 614d6e53..80139def 100644 --- a/LiveControlGateway/LifetimeManager/HubLifetimeManager.cs +++ b/LiveControlGateway/LifetimeManager/HubLifetimeManager.cs @@ -28,6 +28,7 @@ public sealed class HubLifetimeManager private readonly IRedisPubService _redisPubService; private readonly ILoggerFactory _loggerFactory; private readonly GatewayMetrics _metrics; + private readonly LiveControlStatsService _liveControlStats; private readonly ILogger _logger; /// @@ -49,6 +50,7 @@ public sealed class HubLifetimeManager /// /// /// + /// /// public HubLifetimeManager( IDbContextFactory dbContextFactory, @@ -58,6 +60,7 @@ public HubLifetimeManager( ILoggerFactory loggerFactory, LcgOptions lcgOptions, GatewayMetrics metrics, + LiveControlStatsService liveControlStats, [FromKeyedServices("OpenShock.Gateway.Meter")] Meter meter ) { @@ -67,6 +70,7 @@ public HubLifetimeManager( _redisPubService = redisPubService; _loggerFactory = loggerFactory; _metrics = metrics; + _liveControlStats = liveControlStats; _logger = _loggerFactory.CreateLogger(); @@ -201,6 +205,7 @@ private HubLifetime CreateNewLifetime(byte tps, IHubController hubController) _connectionMultiplexer, _redisConnectionProvider, _redisPubService, + _liveControlStats, _loggerFactory.CreateLogger()); return deviceLifetime; diff --git a/LiveControlGateway/LifetimeManager/ShockerState.cs b/LiveControlGateway/LifetimeManager/ShockerState.cs index 7d8e0bd7..954c0e8d 100644 --- a/LiveControlGateway/LifetimeManager/ShockerState.cs +++ b/LiveControlGateway/LifetimeManager/ShockerState.cs @@ -40,6 +40,17 @@ public sealed class ShockerState /// Last duration sent to the shocker via live control /// public ControlType LastType { get; set; } = ControlType.Stop; + + /// + /// User who sent the last live control frame + /// + public Guid LastControllerId { get; set; } + + /// + /// 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 (ControlType Type, Guid ControllerId)? EmittingOnLastTick { get; set; } /// /// Active until time for the shocker, determined by client TPS interval + current time diff --git a/LiveControlGateway/LiveControlStatsService.cs b/LiveControlGateway/LiveControlStatsService.cs new file mode 100644 index 00000000..d06cd8c3 --- /dev/null +++ b/LiveControlGateway/LiveControlStatsService.cs @@ -0,0 +1,73 @@ +using Microsoft.EntityFrameworkCore; +using OpenShock.Common.OpenShockDb; +using OpenShock.Common.Services.ControlStats; + +namespace OpenShock.LiveControlGateway; + +/// +/// Collects live control activity from all hub lifetimes on this gateway and periodically flushes it into the +/// control_stats rollup, so the per-tick hot path never touches the database. +/// +public sealed class LiveControlStatsService : BackgroundService +{ + private static readonly TimeSpan Interval = TimeSpan.FromSeconds(30); + + private readonly IDbContextFactory _dbFactory; + private readonly ILogger _logger; + private readonly ControlStatsAccumulator _pending = new(); + + /// + /// DI constructor + /// + /// + /// + public LiveControlStatsService(IDbContextFactory dbFactory, ILogger logger) + { + _dbFactory = dbFactory; + _logger = logger; + } + + /// + /// Record live control activity + /// + /// + public void Record(ControlStatDelta delta) => _pending.Add(delta); + + /// + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + using var timer = new PeriodicTimer(Interval); + + try + { + while (await timer.WaitForNextTickAsync(stoppingToken)) + { + await Flush(); + } + } + catch (OperationCanceledException) + { + // Shutdown requested, fall through to the final flush + } + + await Flush(); + } + + private async Task Flush() + { + var deltas = _pending.DequeueAll(); + if (deltas.Length == 0) return; + + try + { + await using var db = await _dbFactory.CreateDbContextAsync(); + await db.UpsertControlStatsAsync(deltas); + } + catch (Exception e) + { + // Unlike a log line these can't be rebuilt, so put them back for the next flush to retry + foreach (var delta in deltas) _pending.Add(delta); + _logger.LogError(e, "Failed to flush {Count} live control stat buckets", deltas.Length); + } + } +} diff --git a/LiveControlGateway/Program.cs b/LiveControlGateway/Program.cs index 94e5a187..52661757 100644 --- a/LiveControlGateway/Program.cs +++ b/LiveControlGateway/Program.cs @@ -1,4 +1,4 @@ -using System.Diagnostics.Metrics; +using System.Diagnostics.Metrics; using Microsoft.Extensions.Diagnostics.ResourceMonitoring; using Microsoft.Extensions.Options; using OpenShock.Common; @@ -47,6 +47,8 @@ builder.Services.AddSingleton(); builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddHostedService(sp => sp.GetRequiredService()); builder.Services.AddResourceMonitoring(); // The defaults differ per OS ([0, 1] on Linux, [0, 100] on Windows), so pin both to [0, 1]. #pragma warning disable EXTEXP0008 From 45bce47326137bff5539ee5e79f2afa345462520 Mon Sep 17 00:00:00 2001 From: LucHeart Date: Sun, 11 Oct 2026 17:49:14 +0200 Subject: [PATCH 2/6] feat: bulk control stat changes endpoint for integrations POST /1/integrations/control-stats/changes returns the lifetime totals of the requested users whose stats changed since a cursor, so a service like the Discord bot can keep many users in sync with one request per tick instead of polling each user. Gated by the new ControlStatsRead automation token type. Co-Authored-By: Claude Opus 5.5 --- .../Tests/ControlStatChangesTests.cs | 159 ++ .../Integrations/GetControlStatChanges.cs | 126 ++ API/Controller/Integrations/_ApiController.cs | 22 + API/Errors/ControlStatsError.cs | 1 + .../Requests/ControlStatChangesRequest.cs | 16 + API/Models/Response/ControlStatsResponse.cs | 20 + ...ontrolStatsReadAutomationToken.Designer.cs | 1856 +++++++++++++++++ ...4350_AddControlStatsReadAutomationToken.cs | 77 + .../OpenShockContextModelSnapshot.cs | 4 +- .../OpenShockDb/Enums/AutomationTokenType.cs | 8 +- 10 files changed, 2286 insertions(+), 3 deletions(-) create mode 100644 API.IntegrationTests/Tests/ControlStatChangesTests.cs create mode 100644 API/Controller/Integrations/GetControlStatChanges.cs create mode 100644 API/Controller/Integrations/_ApiController.cs create mode 100644 API/Models/Requests/ControlStatChangesRequest.cs create mode 100644 Common/Migrations/20261011154350_AddControlStatsReadAutomationToken.Designer.cs create mode 100644 Common/Migrations/20261011154350_AddControlStatsReadAutomationToken.cs diff --git a/API.IntegrationTests/Tests/ControlStatChangesTests.cs b/API.IntegrationTests/Tests/ControlStatChangesTests.cs new file mode 100644 index 00000000..d1191034 --- /dev/null +++ b/API.IntegrationTests/Tests/ControlStatChangesTests.cs @@ -0,0 +1,159 @@ +using System.Net; +using System.Text.Json; +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(shockerId, controller, at, count: 2, intensity: 40), + Delta(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(shockerId, owner, old, count: 3), Delta(quietShockerId, quiet, old, count: 1)); + + 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(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); + } + + private static ControlStatDelta Delta(Guid shockerId, Guid? controllerId, DateTime at, long count, byte intensity = 50) => + new(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/Controller/Integrations/GetControlStatChanges.cs b/API/Controller/Integrations/GetControlStatChanges.cs new file mode 100644 index 00000000..5efdd65d --- /dev/null +++ b/API/Controller/Integrations/GetControlStatChanges.cs @@ -0,0 +1,126 @@ +using System.Net.Mime; +using Microsoft.AspNetCore.Mvc; +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. Stats rows are hourly buckets that only + /// ever change while their hour is current, so "changed since" is answered from the bucket indexes. The + /// lookback is widened by to cover buffered live control stats flushed just after + /// the hour rolled over, 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")] + [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 { } since) + { + var from = ControlStatDelta.ToBucket(since.ToUniversalTime() - FlushOverlap); + var recent = _db.ControlStats.Where(x => x.Bucket >= 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); + + changed = owners.Union(controllers).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 the live control gateway's stats flush interval. + private static readonly TimeSpan FlushOverlap = 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/Errors/ControlStatsError.cs b/API/Errors/ControlStatsError.cs index a05bfb83..949796c0 100644 --- a/API/Errors/ControlStatsError.cs +++ b/API/Errors/ControlStatsError.cs @@ -8,5 +8,6 @@ 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 index 21fd2802..1867d05e 100644 --- a/API/Models/Response/ControlStatsResponse.cs +++ b/API/Models/Response/ControlStatsResponse.cs @@ -68,3 +68,23 @@ public sealed class DailyControlStats 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/Migrations/20261011154350_AddControlStatsReadAutomationToken.Designer.cs b/Common/Migrations/20261011154350_AddControlStatsReadAutomationToken.Designer.cs new file mode 100644 index 00000000..4106e588 --- /dev/null +++ b/Common/Migrations/20261011154350_AddControlStatsReadAutomationToken.Designer.cs @@ -0,0 +1,1856 @@ +// +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("20261011154350_AddControlStatsReadAutomationToken")] +partial class AddControlStatsReadAutomationToken +{ + /// + 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.HasKey("Id") + .HasName("control_stats_pkey"); + + b.HasIndex("ShockerId"); + + 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.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/20261011154350_AddControlStatsReadAutomationToken.cs b/Common/Migrations/20261011154350_AddControlStatsReadAutomationToken.cs new file mode 100644 index 00000000..caefa440 --- /dev/null +++ b/Common/Migrations/20261011154350_AddControlStatsReadAutomationToken.cs @@ -0,0 +1,77 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace OpenShock.Common.Migrations; + +/// +public partial class AddControlStatsReadAutomationToken : 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"); + } + + /// + protected override void Down(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") + .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 63e51257..0653178b 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 => "20261010115002_AddControlStats"; + public override string LastMigrationId => "20261011154350_AddControlStatsReadAutomationToken"; 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" }); 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, } From cd9a8ef34daa2e4df43157cf978a33b47c839a0e Mon Sep 17 00:00:00 2001 From: HeavenVR Date: Fri, 16 Oct 2026 12:55:44 +0200 Subject: [PATCH 3/6] refactor: address control stats review feedback - Buffer live control stats in BatchUpdateService instead of a dedicated LiveControlStatsService - Rename GET /1/users/self/stats to /1/users/self/control-stats and put it behind a control-stats concurrency limiter - Resolve the requested date range to UTC in the time zone validation query so the range filters hit the bucket index instead of converting every row - Treat a changes cursor without offset as UTC instead of server local time - Pass the controlling user id as nullable to ControlSender instead of comparing against Guid.Empty - Squash the ControlStatsRead enum migration into AddControlStats - Document the accepted stat loss and change-detection edge cases --- .../Tests/ControlStatsTests.cs | 8 +- API.IntegrationTests/WebApplicationFactory.cs | 2 + .../Integrations/GetControlStatChanges.cs | 14 +- ...GetSelfStats.cs => GetSelfControlStats.cs} | 35 +- Common/Hubs/PublicShareHub.cs | 4 +- ...20261010115002_AddControlStats.Designer.cs | 2 +- .../20261010115002_AddControlStats.cs | 60 + ...ontrolStatsReadAutomationToken.Designer.cs | 1856 ----------------- ...4350_AddControlStatsReadAutomationToken.cs | 77 - .../OpenShockDb/Queries/ControlStatQueries.cs | 3 +- Common/OpenShockServiceHelper.cs | 11 + .../BatchUpdate/BatchUpdateService.cs | 30 +- .../BatchUpdate/IBatchUpdateService.cs | 10 +- Common/Services/ControlSender.cs | 12 +- Common/Services/IControlSender.cs | 2 +- .../LifetimeManager/HubLifetime.cs | 11 +- .../LifetimeManager/HubLifetimeManager.cs | 11 +- LiveControlGateway/LiveControlStatsService.cs | 73 - LiveControlGateway/Program.cs | 4 +- 19 files changed, 174 insertions(+), 2051 deletions(-) rename API/Controller/Users/{GetSelfStats.cs => GetSelfControlStats.cs} (83%) delete mode 100644 Common/Migrations/20261011154350_AddControlStatsReadAutomationToken.Designer.cs delete mode 100644 Common/Migrations/20261011154350_AddControlStatsReadAutomationToken.cs delete mode 100644 LiveControlGateway/LiveControlStatsService.cs diff --git a/API.IntegrationTests/Tests/ControlStatsTests.cs b/API.IntegrationTests/Tests/ControlStatsTests.cs index bda377a6..e4aff5c2 100644 --- a/API.IntegrationTests/Tests/ControlStatsTests.cs +++ b/API.IntegrationTests/Tests/ControlStatsTests.cs @@ -169,10 +169,10 @@ public async Task Stats_InvalidTimeZoneOrRange_Returns400() TestHelper.UniqueEmail("statsbad"), "SecurePassword123#"); using var client = TestHelper.CreateAuthenticatedClient(WebApplicationFactory, user.SessionToken); - var badTz = await client.GetAsync("/1/users/self/stats?tz=Not/AZone"); + 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/stats?from=2026-02-01&to=2026-01-01"); + 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); } @@ -180,7 +180,7 @@ public async Task Stats_InvalidTimeZoneOrRange_Returns400() public async Task Stats_Unauthenticated_Returns401() { using var client = WebApplicationFactory.CreateClient(); - var response = await client.GetAsync("/1/users/self/stats"); + var response = await client.GetAsync("/1/users/self/control-stats"); await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.Unauthorized); } @@ -210,7 +210,7 @@ private async Task CreateShocker(Guid ownerId, string name) private static async Task GetStats(HttpClient client, string query = "") { - var response = await client.GetAsync("/1/users/self/stats" + 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()); } 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 index 5efdd65d..28288925 100644 --- a/API/Controller/Integrations/GetControlStatChanges.cs +++ b/API/Controller/Integrations/GetControlStatChanges.cs @@ -23,7 +23,9 @@ public sealed partial class IntegrationsController /// Requires an automation token granting ControlStatsRead. Stats rows are hourly buckets that only /// ever change while their hour is current, so "changed since" is answered from the bucket indexes. The /// lookback is widened by to cover buffered live control stats flushed just after - /// the hour rolled over, so a user can be reported again although nothing changed. + /// the hour rolled over, so a user can be reported again although nothing changed. Live control stats that + /// only get written after a longer database outage land in an older bucket and are missed, the totals catch up + /// with that user's next control since they are always lifetime totals. /// /// The changed users' totals and the cursor to pass next time. /// No automation token granting ControlStatsRead was presented. @@ -40,9 +42,13 @@ public async Task GetControlStatChanges(ControlStatChangesRequest var userIds = body.UserIds.Distinct().ToArray(); Guid[] changed; - if (body.Since is { } since) + if (body.Since is not null) { - var from = ControlStatDelta.ToBucket(since.ToUniversalTime() - FlushOverlap); + // 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(); + var from = ControlStatDelta.ToBucket(since - FlushOverlap); var recent = _db.ControlStats.Where(x => x.Bucket >= from); var owners = await recent @@ -98,7 +104,7 @@ public async Task GetControlStatChanges(ControlStatChangesRequest }); } - /// Well above the live control gateway's stats flush interval. + /// Well above the batch update service's flush interval, which writes live control stats. private static readonly TimeSpan FlushOverlap = TimeSpan.FromMinutes(5); private static ControlStatTotals[] ToTotals(IEnumerable rows) => rows diff --git a/API/Controller/Users/GetSelfStats.cs b/API/Controller/Users/GetSelfControlStats.cs similarity index 83% rename from API/Controller/Users/GetSelfStats.cs rename to API/Controller/Users/GetSelfControlStats.cs index ef58f5e5..059588e5 100644 --- a/API/Controller/Users/GetSelfStats.cs +++ b/API/Controller/Users/GetSelfControlStats.cs @@ -1,6 +1,7 @@ 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; @@ -19,16 +20,17 @@ 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, defaults to UTC. + /// 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/stats")] + [HttpGet("self/control-stats")] + [EnableRateLimiting("control-stats")] [ProducesResponseType(StatusCodes.Status200OK, MediaTypeNames.Application.Json)] [ProducesResponseType(StatusCodes.Status400BadRequest, MediaTypeNames.Application.ProblemJson)] // InvalidTimeZone, InvalidRange - public async Task GetSelfStats( + public async Task GetSelfControlStats( [FromQuery, MaxLength(64)] string? tz, [FromQuery] DateOnly? from, [FromQuery] DateOnly? to, @@ -37,23 +39,27 @@ public async Task GetSelfStats( tz ??= "UTC"; if (from > to) return Problem(ControlStatsError.InvalidRange); - // Postgres is the one interpreting the time zone, so it is the one validating it. Done upfront since it - // only evaluates AT TIME ZONE once there are rows to apply it to. + // 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 { - await _db.Database.SqlQuery($"SELECT now() AT TIME ZONE {tz} AS \"Value\"").SingleAsync(cancellationToken); + 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 fromLocal = from?.ToDateTime(TimeOnly.MinValue); - var toLocalExclusive = to?.AddDays(1).ToDateTime(TimeOnly.MinValue); - var stats = _db.ControlStats.AsQueryable(); - if (fromLocal is not null) stats = stats.Where(x => TimeZoneInfo.ConvertTimeBySystemTimeZoneId(x.Bucket, tz) >= fromLocal); - if (toLocalExclusive is not null) stats = stats.Where(x => TimeZoneInfo.ConvertTimeBySystemTimeZoneId(x.Bucket, tz) < toLocalExclusive); + 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 @@ -179,6 +185,13 @@ private static ControlStatTotals[] Totals(IEnumerable> rows) }) .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); 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/20261010115002_AddControlStats.Designer.cs b/Common/Migrations/20261010115002_AddControlStats.Designer.cs index 24c924ae..518724e9 100644 --- a/Common/Migrations/20261010115002_AddControlStats.Designer.cs +++ b/Common/Migrations/20261010115002_AddControlStats.Designer.cs @@ -27,7 +27,7 @@ protected override void BuildTargetModel(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" }); diff --git a/Common/Migrations/20261010115002_AddControlStats.cs b/Common/Migrations/20261010115002_AddControlStats.cs index 5853ea22..fae3d48e 100644 --- a/Common/Migrations/20261010115002_AddControlStats.cs +++ b/Common/Migrations/20261010115002_AddControlStats.cs @@ -12,6 +12,36 @@ 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_stats", columns: table => new @@ -74,5 +104,35 @@ protected override void Down(MigrationBuilder migrationBuilder) { 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/20261011154350_AddControlStatsReadAutomationToken.Designer.cs b/Common/Migrations/20261011154350_AddControlStatsReadAutomationToken.Designer.cs deleted file mode 100644 index 4106e588..00000000 --- a/Common/Migrations/20261011154350_AddControlStatsReadAutomationToken.Designer.cs +++ /dev/null @@ -1,1856 +0,0 @@ -// -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("20261011154350_AddControlStatsReadAutomationToken")] -partial class AddControlStatsReadAutomationToken -{ - /// - 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.HasKey("Id") - .HasName("control_stats_pkey"); - - b.HasIndex("ShockerId"); - - 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.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/20261011154350_AddControlStatsReadAutomationToken.cs b/Common/Migrations/20261011154350_AddControlStatsReadAutomationToken.cs deleted file mode 100644 index caefa440..00000000 --- a/Common/Migrations/20261011154350_AddControlStatsReadAutomationToken.cs +++ /dev/null @@ -1,77 +0,0 @@ -using Microsoft.EntityFrameworkCore.Migrations; - -#nullable disable - -namespace OpenShock.Common.Migrations; - -/// -public partial class AddControlStatsReadAutomationToken : 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"); - } - - /// - protected override void Down(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") - .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/OpenShockDb/Queries/ControlStatQueries.cs b/Common/OpenShockDb/Queries/ControlStatQueries.cs index 440fac12..a965c264 100644 --- a/Common/OpenShockDb/Queries/ControlStatQueries.cs +++ b/Common/OpenShockDb/Queries/ControlStatQueries.cs @@ -37,7 +37,8 @@ public static class ControlStatQueries { /// /// Adds the given deltas onto their buckets in a single statement, creating buckets as needed. The owner is - /// resolved through shocker -> device, deltas for shockers that no longer exist are dropped. + /// resolved through shocker -> device, deltas for shockers that no longer exist are dropped. That only + /// loses the live control activity of a shocker deleted within one batch update flush interval, accepted for stats. /// public static Task UpsertControlStatsAsync(this OpenShockContext db, IEnumerable deltas, CancellationToken cancellationToken = default) 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..33c27980 100644 --- a/Common/Services/BatchUpdate/BatchUpdateService.cs +++ b/Common/Services/BatchUpdate/BatchUpdateService.cs @@ -3,6 +3,7 @@ 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 +72,7 @@ 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(); public BatchUpdateService(IDbContextFactory dbFactory, ILogger logger, IConnectionMultiplexer connectionMultiplexer) { @@ -126,6 +128,27 @@ await db.AutomationTokens.Where(x => x.Id == automationTokenId && x.TokenHash == } } + private async Task UpdateControlStats() + { + var deltas = _controlStats.DequeueAll(); + + // Skip if there is nothing + if (deltas.Length < 1) return; + + try + { + await using var db = await _dbFactory.CreateDbContextAsync(); + await db.UpsertControlStatsAsync(deltas); + } + catch + { + // Like use counts these can't be rebuilt, so put them back for the next flush to retry. Still lost if + // the final flush on shutdown fails, which is accepted for statistics. + foreach (var delta in deltas) _controlStats.Add(delta); + throw; + } + } + private async Task UpdateSessions() { var sessionsToUpdate = new List>(); @@ -156,6 +179,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 @@ -192,7 +220,7 @@ private async Task Flush() { try { - await Task.WhenAll(UpdateTokens(), UpdateAutomationTokens(), UpdateSessions()); + await Task.WhenAll(UpdateTokens(), UpdateAutomationTokens(), UpdateControlStats(), UpdateSessions()); } 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 c0599f29..7819408f 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); @@ -171,7 +171,7 @@ 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, @@ -181,7 +181,7 @@ private async Task ControlInternal(IReadOnlyList statDeltas.Add(new ControlStatDelta( ShockerId: shocker.ShockerId, - ControllerUserId: sender.Id == Guid.Empty ? null : sender.Id, + ControllerUserId: controllerUserId, Bucket: now, Type: control.Type, Live: false, 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/LiveControlGateway/LifetimeManager/HubLifetime.cs b/LiveControlGateway/LifetimeManager/HubLifetime.cs index 1f42567c..a937427b 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,7 +51,7 @@ public sealed class HubLifetime : IAsyncDisposable private readonly IDbContextFactory _dbContextFactory; private readonly IRedisConnectionProvider _redisConnectionProvider; private readonly IRedisPubService _redisPubService; - private readonly LiveControlStatsService _liveControlStats; + private readonly IBatchUpdateService _batchUpdateService; private readonly RedisChannel _deviceMsgChannel; private readonly ISubscriber _subscriber; @@ -84,14 +85,14 @@ public sealed class HubLifetime : IAsyncDisposable /// /// /// - /// + /// /// public HubLifetime([Range(1, 10)] byte tps, IHubController hubController, IDbContextFactory dbContextFactory, IConnectionMultiplexer connectionMultiplexer, IRedisConnectionProvider redisConnectionProvider, IRedisPubService redisPubService, - LiveControlStatsService liveControlStats, + IBatchUpdateService batchUpdateService, ILogger logger) { HubController = hubController; @@ -99,7 +100,7 @@ public HubLifetime([Range(1, 10)] byte tps, IHubController hubController, _dbContextFactory = dbContextFactory; _redisConnectionProvider = redisConnectionProvider; _redisPubService = redisPubService; - _liveControlStats = liveControlStats; + _batchUpdateService = batchUpdateService; _logger = logger; _waitBetweenTicks = TimeSpan.FromMilliseconds(1000.0 / tps); @@ -376,7 +377,7 @@ private async Task Update() // Each emitting tick is one tick interval of activity, a new type or controller starts a new burst var emitting = (state.LastType, state.LastControllerId); - _liveControlStats.Record(new ControlStatDelta( + _batchUpdateService.RecordControlStat(new ControlStatDelta( ShockerId: state.Id, ControllerUserId: state.LastControllerId, Bucket: now.UtcDateTime, diff --git a/LiveControlGateway/LifetimeManager/HubLifetimeManager.cs b/LiveControlGateway/LifetimeManager/HubLifetimeManager.cs index 80139def..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,7 +29,7 @@ public sealed class HubLifetimeManager private readonly IRedisPubService _redisPubService; private readonly ILoggerFactory _loggerFactory; private readonly GatewayMetrics _metrics; - private readonly LiveControlStatsService _liveControlStats; + private readonly IBatchUpdateService _batchUpdateService; private readonly ILogger _logger; /// @@ -50,7 +51,7 @@ public sealed class HubLifetimeManager /// /// /// - /// + /// /// public HubLifetimeManager( IDbContextFactory dbContextFactory, @@ -60,7 +61,7 @@ public HubLifetimeManager( ILoggerFactory loggerFactory, LcgOptions lcgOptions, GatewayMetrics metrics, - LiveControlStatsService liveControlStats, + IBatchUpdateService batchUpdateService, [FromKeyedServices("OpenShock.Gateway.Meter")] Meter meter ) { @@ -70,7 +71,7 @@ public HubLifetimeManager( _redisPubService = redisPubService; _loggerFactory = loggerFactory; _metrics = metrics; - _liveControlStats = liveControlStats; + _batchUpdateService = batchUpdateService; _logger = _loggerFactory.CreateLogger(); @@ -205,7 +206,7 @@ private HubLifetime CreateNewLifetime(byte tps, IHubController hubController) _connectionMultiplexer, _redisConnectionProvider, _redisPubService, - _liveControlStats, + _batchUpdateService, _loggerFactory.CreateLogger()); return deviceLifetime; diff --git a/LiveControlGateway/LiveControlStatsService.cs b/LiveControlGateway/LiveControlStatsService.cs deleted file mode 100644 index d06cd8c3..00000000 --- a/LiveControlGateway/LiveControlStatsService.cs +++ /dev/null @@ -1,73 +0,0 @@ -using Microsoft.EntityFrameworkCore; -using OpenShock.Common.OpenShockDb; -using OpenShock.Common.Services.ControlStats; - -namespace OpenShock.LiveControlGateway; - -/// -/// Collects live control activity from all hub lifetimes on this gateway and periodically flushes it into the -/// control_stats rollup, so the per-tick hot path never touches the database. -/// -public sealed class LiveControlStatsService : BackgroundService -{ - private static readonly TimeSpan Interval = TimeSpan.FromSeconds(30); - - private readonly IDbContextFactory _dbFactory; - private readonly ILogger _logger; - private readonly ControlStatsAccumulator _pending = new(); - - /// - /// DI constructor - /// - /// - /// - public LiveControlStatsService(IDbContextFactory dbFactory, ILogger logger) - { - _dbFactory = dbFactory; - _logger = logger; - } - - /// - /// Record live control activity - /// - /// - public void Record(ControlStatDelta delta) => _pending.Add(delta); - - /// - protected override async Task ExecuteAsync(CancellationToken stoppingToken) - { - using var timer = new PeriodicTimer(Interval); - - try - { - while (await timer.WaitForNextTickAsync(stoppingToken)) - { - await Flush(); - } - } - catch (OperationCanceledException) - { - // Shutdown requested, fall through to the final flush - } - - await Flush(); - } - - private async Task Flush() - { - var deltas = _pending.DequeueAll(); - if (deltas.Length == 0) return; - - try - { - await using var db = await _dbFactory.CreateDbContextAsync(); - await db.UpsertControlStatsAsync(deltas); - } - catch (Exception e) - { - // Unlike a log line these can't be rebuilt, so put them back for the next flush to retry - foreach (var delta in deltas) _pending.Add(delta); - _logger.LogError(e, "Failed to flush {Count} live control stat buckets", deltas.Length); - } - } -} diff --git a/LiveControlGateway/Program.cs b/LiveControlGateway/Program.cs index 52661757..94e5a187 100644 --- a/LiveControlGateway/Program.cs +++ b/LiveControlGateway/Program.cs @@ -1,4 +1,4 @@ -using System.Diagnostics.Metrics; +using System.Diagnostics.Metrics; using Microsoft.Extensions.Diagnostics.ResourceMonitoring; using Microsoft.Extensions.Options; using OpenShock.Common; @@ -47,8 +47,6 @@ builder.Services.AddSingleton(); builder.Services.AddSingleton(); -builder.Services.AddSingleton(); -builder.Services.AddHostedService(sp => sp.GetRequiredService()); builder.Services.AddResourceMonitoring(); // The defaults differ per OS ([0, 1] on Linux, [0, 100] on Windows), so pin both to [0, 1]. #pragma warning disable EXTEXP0008 From f781daefca5b369643aa550e67a5d9780e7c30eb Mon Sep 17 00:00:00 2001 From: HeavenVR Date: Fri, 16 Oct 2026 15:56:05 +0200 Subject: [PATCH 4/6] fix: make control stats exact instead of best effort - Carry the owner with each delta and left join shockers, so live activity of a shocker deleted before the flush is kept instead of dropped - Apply live control batches exactly once: each batch keeps its id across retries and is marked applied in the same transaction as its deltas - Park batches the final flush on shutdown couldn't apply in Redis, any instance claims and applies them on its next flush - Detect changes for integrations by a new updated_at column instead of the hourly bucket, so late writes into old buckets are still reported - Clamp the changes lookback so a cursor at DateTime.MinValue no longer throws - Only count live control ticks once they have been written to the hub's websocket, via a send-confirming SendControl on the hub controllers --- .../Tests/ControlStatChangesTests.cs | 66 +++++++++++++++-- .../Tests/ControlStatsTests.cs | 63 +++++++++++++--- .../Integrations/GetControlStatChanges.cs | 24 +++--- .../Services/ControlStatsAccumulatorTests.cs | 3 +- ...0261016135411_AddControlStats.Designer.cs} | 26 ++++++- ...s.cs => 20261016135411_AddControlStats.cs} | 28 ++++++- .../OpenShockContextModelSnapshot.cs | 26 ++++++- Common/OpenShockDb/Models/ControlStat.cs | 6 +- Common/OpenShockDb/Models/ControlStatBatch.cs | 13 ++++ Common/OpenShockDb/OpenShockContext.cs | 18 +++++ .../OpenShockDb/Queries/ControlStatQueries.cs | 55 +++++++++++--- .../BatchUpdate/BatchUpdateService.cs | 74 ++++++++++++++++--- Common/Services/ControlSender.cs | 1 + Common/Websocket/WebsockBaseController.cs | 66 ++++++++++++++--- .../Controllers/HubControllerBase.cs | 4 + .../Controllers/HubV1Controller.cs | 12 ++- .../Controllers/HubV2Controller.cs | 11 ++- .../Controllers/IHubController.cs | 7 ++ .../LifetimeManager/HubLifetime.cs | 40 ++++++---- .../LifetimeManager/ShockerState.cs | 5 ++ 20 files changed, 465 insertions(+), 83 deletions(-) rename Common/Migrations/{20261010115002_AddControlStats.Designer.cs => 20261016135411_AddControlStats.Designer.cs} (98%) rename Common/Migrations/{20261010115002_AddControlStats.cs => 20261016135411_AddControlStats.cs} (91%) create mode 100644 Common/OpenShockDb/Models/ControlStatBatch.cs diff --git a/API.IntegrationTests/Tests/ControlStatChangesTests.cs b/API.IntegrationTests/Tests/ControlStatChangesTests.cs index d1191034..a196e3f0 100644 --- a/API.IntegrationTests/Tests/ControlStatChangesTests.cs +++ b/API.IntegrationTests/Tests/ControlStatChangesTests.cs @@ -1,5 +1,6 @@ using System.Net; using System.Text.Json; +using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; using OpenShock.API.IntegrationTests.Helpers; using OpenShock.Common.Constants; @@ -40,8 +41,8 @@ public async Task InitialSync_ReportsEveryRequestedUser_WithReceivedAndGivenTota var at = new DateTime(2026, 3, 1, 12, 0, 0, DateTimeKind.Utc); await Upsert( - Delta(shockerId, controller, at, count: 2, intensity: 40), - Delta(shockerId, notRequested, at, count: 7)); + 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 } }); @@ -73,7 +74,8 @@ public async Task Since_OnlyReportsUsersWithRecentActivity() // Old activity only, outside of any lookback var old = new DateTime(2026, 1, 1, 12, 0, 0, DateTimeKind.Utc); - await Upsert(Delta(shockerId, owner, old, count: 3), Delta(quietShockerId, quiet, old, count: 1)); + 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 }; @@ -85,7 +87,7 @@ public async Task Since_OnlyReportsUsersWithRecentActivity() using (var unchanged = await Post(client, new { since = cursor, userIds })) await Assert.That(unchanged.RootElement.GetProperty("users").GetArrayLength()).IsEqualTo(0); - await Upsert(Delta(shockerId, owner, DateTime.UtcNow, count: 1)); + 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(); @@ -94,8 +96,60 @@ public async Task Since_OnlyReportsUsersWithRecentActivity() await Assert.That(SingleShock(user.GetProperty("given")).GetProperty("count").GetInt64()).IsEqualTo(4); } - private static ControlStatDelta Delta(Guid shockerId, Guid? controllerId, DateTime at, long count, byte intensity = 50) => - new(shockerId, controllerId, at, ControlType.Shock, Live: false, count, DurationMs: count * 1000, + [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); + } + + /// 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() diff --git a/API.IntegrationTests/Tests/ControlStatsTests.cs b/API.IntegrationTests/Tests/ControlStatsTests.cs index e4aff5c2..174b6c0d 100644 --- a/API.IntegrationTests/Tests/ControlStatsTests.cs +++ b/API.IntegrationTests/Tests/ControlStatsTests.cs @@ -82,8 +82,8 @@ public async Task DeletingShockers_KeepsTheirStats_WithoutUniqueConflicts() // Identical keys apart from the shocker, so after SET NULL they would collide on a full unique index await db.UpsertControlStatsAsync([ - Delta(shockerA, user.Id, bucket, count: 2), - Delta(shockerB, user.Id, bucket, count: 3) + 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(); @@ -113,12 +113,12 @@ public async Task Upsert_MergesDuplicateKeysAndAccumulates() // Same hour, same key twice in one batch, plus a guest (null controller) bucket await db.UpsertControlStatsAsync([ - Delta(shockerId, userId, bucket.AddMinutes(5), count: 1, intensity: 20), - Delta(shockerId, userId, bucket.AddMinutes(50), count: 1, intensity: 80), - Delta(shockerId, null, bucket, count: 4) + Delta(userId, shockerId, userId, bucket.AddMinutes(5), count: 1, intensity: 20), + Delta(userId, shockerId, userId, bucket.AddMinutes(50), count: 1, intensity: 80), + Delta(userId, shockerId, null, bucket, count: 4) ]); - await db.UpsertControlStatsAsync([Delta(shockerId, userId, bucket, count: 1, intensity: 10)]); - await db.UpsertControlStatsAsync([Delta(shockerId, null, bucket, count: 1)]); + 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); @@ -133,6 +133,47 @@ await db.UpsertControlStatsAsync([ 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(); + + await Assert.That(await db.ApplyControlStatBatchAsync(batchId, deltas)).IsTrue(); + // Retry after a commit whose acknowledgement got lost + await Assert.That(await db.ApplyControlStatBatchAsync(batchId, deltas)).IsFalse(); + + var row = await db.ControlStats.AsNoTracking().SingleAsync(x => x.ShockerId == shockerId); + await Assert.That(row.Count).IsEqualTo(2); + } + [Test] public async Task Stats_GroupsDaysInRequestedTimeZone_AndFiltersRange() { @@ -145,8 +186,8 @@ public async Task Stats_GroupsDaysInRequestedTimeZone_AndFiltersRange() var db = scope.ServiceProvider.GetRequiredService(); await db.UpsertControlStatsAsync([ // 23:00 UTC on Jan 1 is already Jan 2 in Berlin (UTC+1) - Delta(shockerId, user.Id, new DateTime(2026, 1, 1, 23, 0, 0, DateTimeKind.Utc), count: 1), - Delta(shockerId, user.Id, new DateTime(2026, 1, 5, 12, 0, 0, DateTimeKind.Utc), count: 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) ]); } @@ -184,8 +225,8 @@ public async Task Stats_Unauthenticated_Returns401() await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.Unauthorized); } - private static ControlStatDelta Delta(Guid shockerId, Guid? controllerId, DateTime at, long count, byte intensity = 50) => - new(shockerId, controllerId, at, ControlType.Shock, Live: false, count, DurationMs: count * 1000, + 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) diff --git a/API/Controller/Integrations/GetControlStatChanges.cs b/API/Controller/Integrations/GetControlStatChanges.cs index 28288925..d5d2b5ed 100644 --- a/API/Controller/Integrations/GetControlStatChanges.cs +++ b/API/Controller/Integrations/GetControlStatChanges.cs @@ -20,12 +20,10 @@ public sealed partial class IntegrationsController /// Returns the lifetime totals of every requested user whose stats changed since the given cursor. /// /// - /// Requires an automation token granting ControlStatsRead. Stats rows are hourly buckets that only - /// ever change while their hour is current, so "changed since" is answered from the bucket indexes. The - /// lookback is widened by to cover buffered live control stats flushed just after - /// the hour rolled over, so a user can be reported again although nothing changed. Live control stats that - /// only get written after a longer database outage land in an older bucket and are missed, the totals catch up - /// with that user's next control since they are always lifetime totals. + /// Requires an automation token granting ControlStatsRead. "Changed since" is answered from when each + /// stats row was last written rather than the hour it covers, so live control stats written late (e.g. after a + /// database outage) are still picked up. 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. @@ -48,8 +46,11 @@ public async Task GetControlStatChanges(ControlStatChangesRequest var since = body.Since.Value.Kind == DateTimeKind.Unspecified ? DateTime.SpecifyKind(body.Since.Value, DateTimeKind.Utc) : body.Since.Value.ToUniversalTime(); - var from = ControlStatDelta.ToBucket(since - FlushOverlap); - var recent = _db.ControlStats.Where(x => x.Bucket >= from); + // 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)) @@ -104,8 +105,11 @@ public async Task GetControlStatChanges(ControlStatChangesRequest }); } - /// Well above the batch update service's flush interval, which writes live control stats. - private static readonly TimeSpan FlushOverlap = TimeSpan.FromMinutes(5); + /// + /// 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) diff --git a/Common.Tests/Services/ControlStatsAccumulatorTests.cs b/Common.Tests/Services/ControlStatsAccumulatorTests.cs index 9156d6a8..f0e4b4f4 100644 --- a/Common.Tests/Services/ControlStatsAccumulatorTests.cs +++ b/Common.Tests/Services/ControlStatsAccumulatorTests.cs @@ -5,11 +5,12 @@ 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(Shocker, Controller, at, type, Live: true, count, DurationMs: 100, IntensityMs: intensity * 100L, intensity); + new(Owner, Shocker, Controller, at, type, Live: true, count, DurationMs: 100, IntensityMs: intensity * 100L, intensity); [Test] public async Task Add_MergesWithinTheSameHour() diff --git a/Common/Migrations/20261010115002_AddControlStats.Designer.cs b/Common/Migrations/20261016135411_AddControlStats.Designer.cs similarity index 98% rename from Common/Migrations/20261010115002_AddControlStats.Designer.cs rename to Common/Migrations/20261016135411_AddControlStats.Designer.cs index 518724e9..dd4ddcc1 100644 --- a/Common/Migrations/20261010115002_AddControlStats.Designer.cs +++ b/Common/Migrations/20261016135411_AddControlStats.Designer.cs @@ -14,7 +14,7 @@ namespace OpenShock.Common.Migrations; [DbContext(typeof(MigrationOpenShockContext))] -[Migration("20261010115002_AddControlStats")] +[Migration("20261016135411_AddControlStats")] partial class AddControlStats { /// @@ -435,11 +435,17 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) .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"); @@ -454,6 +460,24 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) 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.Device", b => { b.Property("Id") diff --git a/Common/Migrations/20261010115002_AddControlStats.cs b/Common/Migrations/20261016135411_AddControlStats.cs similarity index 91% rename from Common/Migrations/20261010115002_AddControlStats.cs rename to Common/Migrations/20261016135411_AddControlStats.cs index fae3d48e..c506cd0a 100644 --- a/Common/Migrations/20261010115002_AddControlStats.cs +++ b/Common/Migrations/20261016135411_AddControlStats.cs @@ -42,6 +42,18 @@ protected override void Up(MigrationBuilder migrationBuilder) .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_stats", columns: table => new @@ -56,7 +68,8 @@ protected override void Up(MigrationBuilder migrationBuilder) 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) + max_intensity = table.Column(type: "smallint", nullable: false), + updated_at = table.Column(type: "timestamp with time zone", nullable: false) }, constraints: table => { @@ -75,6 +88,11 @@ protected override void Up(MigrationBuilder migrationBuilder) onDelete: ReferentialAction.SetNull); }); + migrationBuilder.CreateIndex( + name: "IX_control_stat_batches_applied_at", + table: "control_stat_batches", + column: "applied_at"); + migrationBuilder.CreateIndex( name: "IX_control_stats_controller_user_id_bucket", table: "control_stats", @@ -90,6 +108,11 @@ protected override void Up(MigrationBuilder migrationBuilder) 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", @@ -102,6 +125,9 @@ protected override void Up(MigrationBuilder migrationBuilder) /// protected override void Down(MigrationBuilder migrationBuilder) { + migrationBuilder.DropTable( + name: "control_stat_batches"); + migrationBuilder.DropTable( name: "control_stats"); diff --git a/Common/Migrations/OpenShockContextModelSnapshot.cs b/Common/Migrations/OpenShockContextModelSnapshot.cs index 0653178b..5b7e6c76 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 => "20261011154350_AddControlStatsReadAutomationToken"; + public override string LastMigrationId => "20261016135411_AddControlStats"; protected override void BuildModel(ModelBuilder modelBuilder) { @@ -437,11 +437,17 @@ protected override void BuildModel(ModelBuilder modelBuilder) .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"); @@ -456,6 +462,24 @@ protected override void BuildModel(ModelBuilder modelBuilder) 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.Device", b => { b.Property("Id") diff --git a/Common/OpenShockDb/Models/ControlStat.cs b/Common/OpenShockDb/Models/ControlStat.cs index b8df7f20..bf918b4b 100644 --- a/Common/OpenShockDb/Models/ControlStat.cs +++ b/Common/OpenShockDb/Models/ControlStat.cs @@ -2,7 +2,8 @@ namespace OpenShock.Common.OpenShockDb; /// /// Hourly rollup of shocker control activity. Unlike these rows are never trimmed, -/// so they back lifetime statistics. One row per owner, shocker, controller, hour, type and live flag. +/// so they back lifetime statistics. One row per owner, shocker, controller, hour, 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 { @@ -38,6 +39,9 @@ public sealed class ControlStat 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/OpenShockContext.cs b/Common/OpenShockDb/OpenShockContext.cs index c5b78127..45865bb7 100644 --- a/Common/OpenShockDb/OpenShockContext.cs +++ b/Common/OpenShockDb/OpenShockContext.cs @@ -87,6 +87,8 @@ public static void ConfigureOptionsBuilder(DbContextOptionsBuilder optionsBuilde public DbSet ControlStats { get; set; } + public DbSet ControlStatBatches { get; set; } + public DbSet UserShares { get; set; } public DbSet ShockerShareCodes { get; set; } @@ -470,6 +472,7 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) 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() @@ -484,6 +487,7 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) 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) @@ -496,6 +500,20 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) .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"); + }); + 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 index a965c264..39bb2cff 100644 --- a/Common/OpenShockDb/Queries/ControlStatQueries.cs +++ b/Common/OpenShockDb/Queries/ControlStatQueries.cs @@ -6,6 +6,7 @@ namespace OpenShock.Common.OpenShockDb; /// A pending increment for one bucket. /// public readonly record struct ControlStatDelta( + Guid OwnerUserId, Guid ShockerId, Guid? ControllerUserId, DateTime Bucket, @@ -36,9 +37,15 @@ public ControlStatDelta Merge(ControlStatDelta other) => this with public static class ControlStatQueries { /// - /// Adds the given deltas onto their buckets in a single statement, creating buckets as needed. The owner is - /// resolved through shocker -> device, deltas for shockers that no longer exist are dropped. That only - /// loses the live control activity of a shocker deleted within one batch update flush interval, accepted for stats. + /// How long markers are kept. Batches are retried every flush, so a marker only has + /// to outlive the retries of a batch whose commit acknowledgement got lost. + /// + private static readonly TimeSpan BatchMarkerRetention = 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) @@ -60,6 +67,7 @@ public static Task UpsertControlStatsAsync(this OpenShockContext db, IEnume 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(); @@ -70,20 +78,47 @@ public static Task UpsertControlStatsAsync(this OpenShockContext db, IEnume 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) - SELECT v.id, d.owner_id, v.shocker_id, v.controller_user_id, v.bucket, v.type, v.live, v.count, v.duration_ms, v.intensity_ms, v.max_intensity - FROM unnest({ids}, {shockerIds}, {controllerIds}, {buckets}, {types}, {lives}, {counts}, {durations}, {intensityMs}, {maxIntensities}) - AS v(id, shocker_id, controller_user_id, bucket, type, live, count, duration_ms, intensity_ms, max_intensity) - JOIN shockers s ON s.id = v.shocker_id - JOIN devices d ON d.id = s.device_id + 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) + 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. + /// + /// False if the batch had already been applied. + public static async Task ApplyControlStatBatchAsync(this OpenShockContext db, Guid batchId, + IEnumerable deltas, CancellationToken cancellationToken = default) + { + 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 false; + + await db.UpsertControlStatsAsync(deltas, cancellationToken); + + var expiredBefore = DateTime.UtcNow - BatchMarkerRetention; + await db.ControlStatBatches.Where(x => x.AppliedAt < expiredBefore).ExecuteDeleteAsync(cancellationToken); + + await transaction.CommitAsync(cancellationToken); + return true; + } } diff --git a/Common/Services/BatchUpdate/BatchUpdateService.cs b/Common/Services/BatchUpdate/BatchUpdateService.cs index 33c27980..5c849a0f 100644 --- a/Common/Services/BatchUpdate/BatchUpdateService.cs +++ b/Common/Services/BatchUpdate/BatchUpdateService.cs @@ -1,4 +1,5 @@ -using Microsoft.EntityFrameworkCore; +using System.Text.Json; +using Microsoft.EntityFrameworkCore; using NRedisStack.RedisStackCommands; using OpenShock.Common.Constants; using OpenShock.Common.OpenShockDb; @@ -74,6 +75,17 @@ public sealed class BatchUpdateService : BackgroundService, IBatchUpdateService 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"; + public BatchUpdateService(IDbContextFactory dbFactory, ILogger logger, IConnectionMultiplexer connectionMultiplexer) { _dbFactory = dbFactory; @@ -130,25 +142,66 @@ await db.AutomationTokens.Where(x => x.Id == automationTokenId && x.TokenHash == private async Task UpdateControlStats() { + await ClaimParkedControlStatBatches(); + var deltas = _controlStats.DequeueAll(); + if (deltas.Length > 0) _controlStatBatches.Enqueue(new PendingControlStatBatch(Guid.CreateVersion7(), deltas)); - // Skip if there is nothing - if (deltas.Length < 1) return; + // A batch that throws 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 using var db = await _dbFactory.CreateDbContextAsync(); + await db.ApplyControlStatBatchAsync(batch.Id, batch.Deltas); + _controlStatBatches.Dequeue(); + } + } + private async Task ClaimParkedControlStatBatches() + { try { - await using var db = await _dbFactory.CreateDbContextAsync(); - await db.UpsertControlStatsAsync(deltas); + var parked = await _connectionMultiplexer.GetDatabase().ListLeftPopAsync(ParkedControlStatBatchesKey, 100); + if (parked is null) return; + + foreach (var value in parked) + { + var batch = JsonSerializer.Deserialize(value.ToString()); + if (batch is not null) _controlStatBatches.Enqueue(batch); + } } - catch + catch (Exception e) { - // Like use counts these can't be rebuilt, so put them back for the next flush to retry. Still lost if - // the final flush on shutdown fails, which is accepted for statistics. - foreach (var delta in deltas) _controlStats.Add(delta); - throw; + // Left in Redis, the next flush tries again + _logger.LogWarning(e, "Failed to claim parked control stat batches"); } } + /// + /// Stats can't be rebuilt, so whatever the final flush couldn't apply is handed to Redis for the next instance + /// instead of being dropped with this process. + /// + private async Task ParkUnappliedControlStatBatches() + { + var late = _controlStats.DequeueAll(); + if (late.Length > 0) _controlStatBatches.Enqueue(new PendingControlStatBatch(Guid.CreateVersion7(), late)); + if (_controlStatBatches.Count == 0) return; + + try + { + var values = _controlStatBatches.Select(b => (RedisValue)JsonSerializer.Serialize(b)).ToArray(); + await _connectionMultiplexer.GetDatabase().ListRightPushAsync(ParkedControlStatBatchesKey, values); + _logger.LogWarning("Parked {Count} unapplied control stat batches in Redis for another instance", values.Length); + } + catch (Exception e) + { + _logger.LogError(e, "Lost {Count} control stat batches on shutdown, neither the database nor Redis took them", + _controlStatBatches.Count); + } + } + + private sealed record PendingControlStatBatch(Guid Id, ControlStatDelta[] Deltas); + private async Task UpdateSessions() { var sessionsToUpdate = new List>(); @@ -214,6 +267,7 @@ protected override async Task ExecuteAsync(CancellationToken stoppingToken) // Drain what was queued since the last tick, otherwise every shutdown // silently loses up to Interval worth of last used updates await Flush(); + await ParkUnappliedControlStatBatches(); } private async Task Flush() diff --git a/Common/Services/ControlSender.cs b/Common/Services/ControlSender.cs index 7819408f..07968f7a 100644 --- a/Common/Services/ControlSender.cs +++ b/Common/Services/ControlSender.cs @@ -180,6 +180,7 @@ private async Task ControlInternal(IReadOnlyList }); statDeltas.Add(new ControlStatDelta( + OwnerUserId: shocker.OwnerId, ShockerId: shocker.ShockerId, ControllerUserId: controllerUserId, Bucket: now, 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/LifetimeManager/HubLifetime.cs b/LiveControlGateway/LifetimeManager/HubLifetime.cs index a937427b..12b1b051 100644 --- a/LiveControlGateway/LifetimeManager/HubLifetime.cs +++ b/LiveControlGateway/LifetimeManager/HubLifetime.cs @@ -365,7 +365,7 @@ private async Task Update() { var now = DateTimeOffset.UtcNow; var commandList = new List(_shockerStates.Count); - var tickMs = (long)_waitBetweenTicks.TotalMilliseconds; + var emittingStates = new List<(ShockerState State, ControlType Type, Guid ControllerId, byte Intensity)>(_shockerStates.Count); foreach (var state in _shockerStates.Values) { @@ -375,19 +375,8 @@ private async Task Update() continue; } - // Each emitting tick is one tick interval of activity, a new type or controller starts a new burst - var emitting = (state.LastType, state.LastControllerId); - _batchUpdateService.RecordControlStat(new ControlStatDelta( - ShockerId: state.Id, - ControllerUserId: state.LastControllerId, - Bucket: now.UtcDateTime, - Type: state.LastType, - Live: true, - Count: state.EmittingOnLastTick == emitting ? 0 : 1, - DurationMs: tickMs, - IntensityMs: state.LastIntensity * tickMs, - MaxIntensity: state.LastIntensity)); - state.EmittingOnLastTick = emitting; + // Snapshot what is sent, a frame arriving while the send is awaited must not change what gets counted + emittingStates.Add((state, state.LastType, state.LastControllerId, state.LastIntensity)); commandList.Add(new ShockerCommand { @@ -401,7 +390,27 @@ private async Task Update() 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, type, controllerId, intensity) in emittingStates) + { + // Each sent tick is one tick interval of activity, a new type or controller starts a new burst + var emitting = (type, controllerId); + _batchUpdateService.RecordControlStat(new ControlStatDelta( + OwnerUserId: state.OwnerId, + ShockerId: state.Id, + ControllerUserId: controllerId, + Bucket: now.UtcDateTime, + Type: type, + Live: true, + Count: state.EmittingOnLastTick == emitting ? 0 : 1, + DurationMs: tickMs, + IntensityMs: intensity * tickMs, + MaxIntensity: intensity)); + state.EmittingOnLastTick = emitting; + } } /// @@ -426,6 +435,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); diff --git a/LiveControlGateway/LifetimeManager/ShockerState.cs b/LiveControlGateway/LifetimeManager/ShockerState.cs index 954c0e8d..ce5abe98 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 /// From 81bdbe6175cc1aa959a5cd53836672f3c8a0ccc3 Mon Sep 17 00:00:00 2001 From: HeavenVR Date: Fri, 16 Oct 2026 16:26:40 +0200 Subject: [PATCH 5/6] fix: close remaining control stats review findings - Refuse batches older than a 30 day replay lifetime and only prune batch markers after it, so a marker can't expire while its batch may still be retried - Read parked batches from Redis without removing them and remove each one only after it was applied, discarding malformed entries one by one - Keep offering unapplied batches to the database and Redis for up to 20s on shutdown, logging them for manual recovery if neither takes them - Store each live control frame as one immutable record so the command and its stats are always built from the same frame - Record users whose stats rows get deleted in control_stat_removals via a statement level trigger, and report them from the changes endpoint - Use 15 minute buckets so local days start on a bucket boundary in every time zone, including half and quarter hour offsets --- .../Tests/ControlStatChangesTests.cs | 29 ++++ .../Tests/ControlStatsTests.cs | 56 +++++++- .../Integrations/GetControlStatChanges.cs | 16 ++- .../Services/ControlStatsAccumulatorTests.cs | 18 +-- ...0261016142540_AddControlStats.Designer.cs} | 20 ++- ...s.cs => 20261016142540_AddControlStats.cs} | 50 +++++++ .../OpenShockContextModelSnapshot.cs | 20 ++- Common/OpenShockDb/Models/ControlStat.cs | 8 +- .../OpenShockDb/Models/ControlStatRemoval.cs | 14 ++ Common/OpenShockDb/OpenShockContext.cs | 17 +++ .../OpenShockDb/Queries/ControlStatQueries.cs | 51 +++++-- .../BatchUpdate/BatchUpdateService.cs | 133 +++++++++++++----- .../LifetimeManager/HubLifetime.cs | 30 ++-- .../LifetimeManager/ShockerState.cs | 36 +++-- 14 files changed, 396 insertions(+), 102 deletions(-) rename Common/Migrations/{20261016135411_AddControlStats.Designer.cs => 20261016142540_AddControlStats.Designer.cs} (99%) rename Common/Migrations/{20261016135411_AddControlStats.cs => 20261016142540_AddControlStats.cs} (84%) create mode 100644 Common/OpenShockDb/Models/ControlStatRemoval.cs diff --git a/API.IntegrationTests/Tests/ControlStatChangesTests.cs b/API.IntegrationTests/Tests/ControlStatChangesTests.cs index a196e3f0..49ec6549 100644 --- a/API.IntegrationTests/Tests/ControlStatChangesTests.cs +++ b/API.IntegrationTests/Tests/ControlStatChangesTests.cs @@ -138,6 +138,35 @@ public async Task Since_ReportsLateWritesIntoOldBuckets() 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) { diff --git a/API.IntegrationTests/Tests/ControlStatsTests.cs b/API.IntegrationTests/Tests/ControlStatsTests.cs index 174b6c0d..620925ba 100644 --- a/API.IntegrationTests/Tests/ControlStatsTests.cs +++ b/API.IntegrationTests/Tests/ControlStatsTests.cs @@ -111,10 +111,10 @@ public async Task Upsert_MergesDuplicateKeysAndAccumulates() await using var scope = WebApplicationFactory.Services.CreateAsyncScope(); var db = scope.ServiceProvider.GetRequiredService(); - // Same hour, same key twice in one batch, plus a guest (null controller) bucket + // 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(50), count: 1, intensity: 80), + 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)]); @@ -166,14 +166,34 @@ public async Task ApplyBatch_RetriedWithSameId_IsCountedOnce() await using var scope = WebApplicationFactory.Services.CreateAsyncScope(); var db = scope.ServiceProvider.GetRequiredService(); - await Assert.That(await db.ApplyControlStatBatchAsync(batchId, deltas)).IsTrue(); + 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, deltas)).IsFalse(); + 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() { @@ -203,6 +223,34 @@ await db.UpsertControlStatsAsync([ 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() { diff --git a/API/Controller/Integrations/GetControlStatChanges.cs b/API/Controller/Integrations/GetControlStatChanges.cs index d5d2b5ed..7b87c784 100644 --- a/API/Controller/Integrations/GetControlStatChanges.cs +++ b/API/Controller/Integrations/GetControlStatChanges.cs @@ -21,9 +21,11 @@ public sealed partial class IntegrationsController /// /// /// Requires an automation token granting ControlStatsRead. "Changed since" is answered from when each - /// stats row was last written rather than the hour it covers, so live control stats written late (e.g. after a - /// database outage) are still picked up. 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. + /// 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. @@ -63,7 +65,13 @@ public async Task GetControlStatChanges(ControlStatChangesRequest .Distinct() .ToArrayAsync(cancellationToken); - changed = owners.Union(controllers).ToArray(); + // 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 { diff --git a/Common.Tests/Services/ControlStatsAccumulatorTests.cs b/Common.Tests/Services/ControlStatsAccumulatorTests.cs index f0e4b4f4..475e5caf 100644 --- a/Common.Tests/Services/ControlStatsAccumulatorTests.cs +++ b/Common.Tests/Services/ControlStatsAccumulatorTests.cs @@ -13,15 +13,15 @@ private static ControlStatDelta Delta(DateTime at, ControlType type = ControlTyp new(Owner, Shocker, Controller, at, type, Live: true, count, DurationMs: 100, IntensityMs: intensity * 100L, intensity); [Test] - public async Task Add_MergesWithinTheSameHour() + public async Task Add_MergesWithinTheSameBucket() { var accumulator = new ControlStatsAccumulator(); - accumulator.Add(Delta(new DateTime(2026, 1, 1, 10, 5, 0, DateTimeKind.Utc), count: 1, intensity: 20)); - accumulator.Add(Delta(new DateTime(2026, 1, 1, 10, 55, 0, DateTimeKind.Utc), count: 0, intensity: 80)); + 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, 0, 0, DateTimeKind.Utc)); + 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); @@ -29,13 +29,13 @@ public async Task Add_MergesWithinTheSameHour() } [Test] - public async Task Add_SplitsAcrossHoursAndTypes_AndIgnoresStop() + public async Task Add_SplitsAcrossBucketsAndTypes_AndIgnoresStop() { var accumulator = new ControlStatsAccumulator(); - accumulator.Add(Delta(new DateTime(2026, 1, 1, 10, 59, 0, DateTimeKind.Utc))); - accumulator.Add(Delta(new DateTime(2026, 1, 1, 11, 0, 0, DateTimeKind.Utc))); - accumulator.Add(Delta(new DateTime(2026, 1, 1, 11, 0, 0, DateTimeKind.Utc), ControlType.Vibrate)); - accumulator.Add(Delta(new DateTime(2026, 1, 1, 11, 0, 0, DateTimeKind.Utc), ControlType.Stop)); + 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); } diff --git a/Common/Migrations/20261016135411_AddControlStats.Designer.cs b/Common/Migrations/20261016142540_AddControlStats.Designer.cs similarity index 99% rename from Common/Migrations/20261016135411_AddControlStats.Designer.cs rename to Common/Migrations/20261016142540_AddControlStats.Designer.cs index dd4ddcc1..d2f96fa4 100644 --- a/Common/Migrations/20261016135411_AddControlStats.Designer.cs +++ b/Common/Migrations/20261016142540_AddControlStats.Designer.cs @@ -14,7 +14,7 @@ namespace OpenShock.Common.Migrations; [DbContext(typeof(MigrationOpenShockContext))] -[Migration("20261016135411_AddControlStats")] +[Migration("20261016142540_AddControlStats")] partial class AddControlStats { /// @@ -478,6 +478,24 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) 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") diff --git a/Common/Migrations/20261016135411_AddControlStats.cs b/Common/Migrations/20261016142540_AddControlStats.cs similarity index 84% rename from Common/Migrations/20261016135411_AddControlStats.cs rename to Common/Migrations/20261016142540_AddControlStats.cs index c506cd0a..9858b38b 100644 --- a/Common/Migrations/20261016135411_AddControlStats.cs +++ b/Common/Migrations/20261016142540_AddControlStats.cs @@ -54,6 +54,18 @@ protected override void Up(MigrationBuilder migrationBuilder) 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 @@ -93,6 +105,11 @@ protected override void Up(MigrationBuilder migrationBuilder) 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", @@ -120,14 +137,47 @@ protected override void Up(MigrationBuilder migrationBuilder) 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"); diff --git a/Common/Migrations/OpenShockContextModelSnapshot.cs b/Common/Migrations/OpenShockContextModelSnapshot.cs index 5b7e6c76..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 => "20261016135411_AddControlStats"; + public override string LastMigrationId => "20261016142540_AddControlStats"; protected override void BuildModel(ModelBuilder modelBuilder) { @@ -480,6 +480,24 @@ protected override void BuildModel(ModelBuilder modelBuilder) 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") diff --git a/Common/OpenShockDb/Models/ControlStat.cs b/Common/OpenShockDb/Models/ControlStat.cs index bf918b4b..abfe424e 100644 --- a/Common/OpenShockDb/Models/ControlStat.cs +++ b/Common/OpenShockDb/Models/ControlStat.cs @@ -1,8 +1,10 @@ namespace OpenShock.Common.OpenShockDb; /// -/// Hourly rollup of shocker control activity. Unlike these rows are never trimmed, -/// so they back lifetime statistics. One row per owner, shocker, controller, hour, type and live flag. Live control +/// 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 @@ -21,7 +23,7 @@ public sealed class ControlStat /// public Guid? ControllerUserId { get; set; } - /// Start of the UTC hour this row aggregates. + /// Start of the 15 minute UTC bucket this row aggregates, see . public required DateTime Bucket { get; set; } public required ControlType Type { 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/OpenShockContext.cs b/Common/OpenShockDb/OpenShockContext.cs index 45865bb7..7191fef7 100644 --- a/Common/OpenShockDb/OpenShockContext.cs +++ b/Common/OpenShockDb/OpenShockContext.cs @@ -89,6 +89,8 @@ public static void ConfigureOptionsBuilder(DbContextOptionsBuilder optionsBuilde public DbSet ControlStatBatches { get; set; } + public DbSet ControlStatRemovals { get; set; } + public DbSet UserShares { get; set; } public DbSet ShockerShareCodes { get; set; } @@ -514,6 +516,21 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) 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 index 39bb2cff..e2cf9ed5 100644 --- a/Common/OpenShockDb/Queries/ControlStatQueries.cs +++ b/Common/OpenShockDb/Queries/ControlStatQueries.cs @@ -17,9 +17,12 @@ public readonly record struct ControlStatDelta( long IntensityMs, byte MaxIntensity) { - /// Truncates a UTC timestamp to the start of its hour, the granularity of . + /// 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 % TimeSpan.TicksPerHour, DateTimeKind.Utc); + new(utc.Ticks - utc.Ticks % BucketSize.Ticks, DateTimeKind.Utc); /// Combines two deltas for the same bucket. public ControlStatDelta Merge(ControlStatDelta other) => this with @@ -37,10 +40,17 @@ public ControlStatDelta Merge(ControlStatDelta other) => this with public static class ControlStatQueries { /// - /// How long markers are kept. Batches are retried every flush, so a marker only has - /// to outlive the retries of a batch whose commit acknowledgement got lost. + /// 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 BatchMarkerRetention = TimeSpan.FromDays(1); + 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 @@ -101,24 +111,41 @@ DO UPDATE SET /// /// 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. /// - /// False if the batch had already been applied. - public static async Task ApplyControlStatBatchAsync(this OpenShockContext db, Guid batchId, - IEnumerable deltas, CancellationToken cancellationToken = default) + 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 false; + if (marked == 0) return ControlStatBatchResult.AlreadyApplied; await db.UpsertControlStatsAsync(deltas, cancellationToken); - var expiredBefore = DateTime.UtcNow - BatchMarkerRetention; - await db.ControlStatBatches.Where(x => x.AppliedAt < expiredBefore).ExecuteDeleteAsync(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 true; + 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/Services/BatchUpdate/BatchUpdateService.cs b/Common/Services/BatchUpdate/BatchUpdateService.cs index 5c849a0f..90e14a05 100644 --- a/Common/Services/BatchUpdate/BatchUpdateService.cs +++ b/Common/Services/BatchUpdate/BatchUpdateService.cs @@ -1,4 +1,5 @@ -using System.Text.Json; +using System.Diagnostics; +using System.Text.Json; using Microsoft.EntityFrameworkCore; using NRedisStack.RedisStackCommands; using OpenShock.Common.Constants; @@ -86,6 +87,12 @@ public sealed class BatchUpdateService : BackgroundService, IBatchUpdateService /// 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); + public BatchUpdateService(IDbContextFactory dbFactory, ILogger logger, IConnectionMultiplexer connectionMultiplexer) { _dbFactory = dbFactory; @@ -142,65 +149,125 @@ await db.AutomationTokens.Where(x => x.Id == automationTokenId && x.TokenHash == private async Task UpdateControlStats() { - await ClaimParkedControlStatBatches(); + EnqueueRecordedControlStats(); + await ApplyParkedControlStatBatches(); + await ApplyQueuedControlStatBatches(); + } + private void EnqueueRecordedControlStats() + { var deltas = _controlStats.DequeueAll(); - if (deltas.Length > 0) _controlStatBatches.Enqueue(new PendingControlStatBatch(Guid.CreateVersion7(), deltas)); + if (deltas.Length > 0) + _controlStatBatches.Enqueue(new PendingControlStatBatch(Guid.CreateVersion7(), DateTime.UtcNow, deltas)); + } + private async Task ApplyQueuedControlStatBatches() + { // A batch that throws 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 using var db = await _dbFactory.CreateDbContextAsync(); - await db.ApplyControlStatBatchAsync(batch.Id, batch.Deltas); + await ApplyControlStatBatch(batch); _controlStatBatches.Dequeue(); } } - private async Task ClaimParkedControlStatBatches() + private async Task ApplyControlStatBatch(PendingControlStatBatch batch) { - try - { - var parked = await _connectionMultiplexer.GetDatabase().ListLeftPopAsync(ParkedControlStatBatchesKey, 100); - if (parked is null) return; - - foreach (var value in parked) - { - var batch = JsonSerializer.Deserialize(value.ToString()); - if (batch is not null) _controlStatBatches.Enqueue(batch); - } - } - catch (Exception e) + await using var db = await _dbFactory.CreateDbContextAsync(); + var result = await db.ApplyControlStatBatchAsync(batch.Id, batch.CreatedAt, batch.Deltas); + if (result == ControlStatBatchResult.Expired) { - // Left in Redis, the next flush tries again - _logger.LogWarning(e, "Failed to claim parked control stat batches"); + _logger.LogError("Dropped control stat batch {BatchId} from {CreatedAt}, it outlived the replay lifetime: {Batch}", + batch.Id, batch.CreatedAt, JsonSerializer.Serialize(batch)); } } /// - /// Stats can't be rebuilt, so whatever the final flush couldn't apply is handed to Redis for the next instance - /// instead of being dropped with this process. + /// 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 ParkUnappliedControlStatBatches() + private async Task ApplyParkedControlStatBatches() { - var late = _controlStats.DequeueAll(); - if (late.Length > 0) _controlStatBatches.Enqueue(new PendingControlStatBatch(Guid.CreateVersion7(), late)); - if (_controlStatBatches.Count == 0) return; + var redis = _connectionMultiplexer.GetDatabase(); + RedisValue[] parked; try { - var values = _controlStatBatches.Select(b => (RedisValue)JsonSerializer.Serialize(b)).ToArray(); - await _connectionMultiplexer.GetDatabase().ListRightPushAsync(ParkedControlStatBatchesKey, values); - _logger.LogWarning("Parked {Count} unapplied control stat batches in Redis for another instance", values.Length); + parked = await redis.ListRangeAsync(ParkedControlStatBatchesKey, 0, 99); } catch (Exception e) { - _logger.LogError(e, "Lost {Count} control stat batches on shutdown, neither the database nor Redis took them", - _controlStatBatches.Count); + // 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, then 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() + { + EnqueueRecordedControlStats(); + + var started = Stopwatch.GetTimestamp(); + while (_controlStatBatches.Count > 0) + { + try + { + await ApplyQueuedControlStatBatches(); + return; + } + catch (Exception e) + { + _logger.LogWarning(e, "Failed to apply {Count} control stat batches on shutdown", _controlStatBatches.Count); + } + + try + { + var values = _controlStatBatches.Select(b => (RedisValue)JsonSerializer.Serialize(b)).ToArray(); + await _connectionMultiplexer.GetDatabase().ListRightPushAsync(ParkedControlStatBatchesKey, values); + _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); + } + + if (Stopwatch.GetElapsedTime(started) >= ShutdownHandOffTimeout) break; + await Task.Delay(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, ControlStatDelta[] Deltas); + private sealed record PendingControlStatBatch(Guid Id, DateTime CreatedAt, ControlStatDelta[] Deltas); private async Task UpdateSessions() { @@ -267,7 +334,7 @@ protected override async Task ExecuteAsync(CancellationToken stoppingToken) // Drain what was queued since the last tick, otherwise every shutdown // silently loses up to Interval worth of last used updates await Flush(); - await ParkUnappliedControlStatBatches(); + await HandOffControlStatsOnShutdown(); } private async Task Flush() diff --git a/LiveControlGateway/LifetimeManager/HubLifetime.cs b/LiveControlGateway/LifetimeManager/HubLifetime.cs index 12b1b051..2bed7997 100644 --- a/LiveControlGateway/LifetimeManager/HubLifetime.cs +++ b/LiveControlGateway/LifetimeManager/HubLifetime.cs @@ -365,25 +365,26 @@ private async Task Update() { var now = DateTimeOffset.UtcNow; var commandList = new List(_shockerStates.Count); - var emittingStates = new List<(ShockerState State, ControlType Type, Guid ControllerId, byte Intensity)>(_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) + // 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; } - // Snapshot what is sent, a frame arriving while the send is awaited must not change what gets counted - emittingStates.Add((state, state.LastType, state.LastControllerId, state.LastIntensity)); + 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, }); } @@ -394,21 +395,21 @@ private async Task Update() if (!await HubController.SendControl(commandList)) return; var tickMs = (long)_waitBetweenTicks.TotalMilliseconds; - foreach (var (state, type, controllerId, intensity) in emittingStates) + 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 = (type, controllerId); + var emitting = (frame.Type, frame.ControllerId); _batchUpdateService.RecordControlStat(new ControlStatDelta( OwnerUserId: state.OwnerId, ShockerId: state.Id, - ControllerUserId: controllerId, + ControllerUserId: frame.ControllerId, Bucket: now.UtcDateTime, - Type: type, + Type: frame.Type, Live: true, Count: state.EmittingOnLastTick == emitting ? 0 : 1, DurationMs: tickMs, - IntensityMs: intensity * tickMs, - MaxIntensity: intensity)); + IntensityMs: frame.Intensity * tickMs, + MaxIntensity: frame.Intensity)); state.EmittingOnLastTick = emitting; } } @@ -460,10 +461,7 @@ public Union3 ReceiveFrame(Guid shocker, Gu Until = state.ExclusiveUntil }; - state.LastType = type; - state.LastIntensity = intensity; - state.LastControllerId = controllerId; - state.ActiveUntil = CalculateActiveUntil(tps); + state.LastFrame = new LiveFrame(type, intensity, controllerId, CalculateActiveUntil(tps)); return new Success(); } diff --git a/LiveControlGateway/LifetimeManager/ShockerState.cs b/LiveControlGateway/LifetimeManager/ShockerState.cs index ce5abe98..e726ff23 100644 --- a/LiveControlGateway/LifetimeManager/ShockerState.cs +++ b/LiveControlGateway/LifetimeManager/ShockerState.cs @@ -36,20 +36,10 @@ public sealed class ShockerState // Last state /// - /// Last intensity 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. /// - [Range(HardLimits.MinControlIntensity, HardLimits.MaxControlIntensity)] - public byte LastIntensity { get; set; } = 0; - - /// - /// Last duration sent to the shocker via live control - /// - public ControlType LastType { get; set; } = ControlType.Stop; - - /// - /// User who sent the last live control frame - /// - public Guid LastControllerId { get; set; } + public LiveFrame? LastFrame { get; set; } /// /// Whether the shocker was emitting live control on the previous tick, with which type and by whom. @@ -57,13 +47,21 @@ public sealed class ShockerState /// public (ControlType Type, Guid ControllerId)? EmittingOnLastTick { get; set; } - /// - /// Active until time for the shocker, determined by client TPS interval + current time - /// - public DateTimeOffset ActiveUntil = DateTimeOffset.MinValue; - /// /// 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); From fa93c071a9630a862e02316c82b7853c97cf9031 Mon Sep 17 00:00:00 2001 From: HeavenVR Date: Fri, 16 Oct 2026 18:05:26 +0200 Subject: [PATCH 6/6] fix: bound shutdown stat hand-off and limit control stat changes concurrency Apply the control-stats concurrency limiter to the integration changes endpoint, and propagate a deadline through shutdown database writes so a stalled database still leaves time to park control stat batches in Redis. --- .../Integrations/GetControlStatChanges.cs | 2 + .../BatchUpdate/BatchUpdateService.cs | 98 ++++++++++++------- 2 files changed, 62 insertions(+), 38 deletions(-) diff --git a/API/Controller/Integrations/GetControlStatChanges.cs b/API/Controller/Integrations/GetControlStatChanges.cs index 7b87c784..29b8460e 100644 --- a/API/Controller/Integrations/GetControlStatChanges.cs +++ b/API/Controller/Integrations/GetControlStatChanges.cs @@ -1,5 +1,6 @@ using System.Net.Mime; using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.RateLimiting; using Microsoft.EntityFrameworkCore; using OpenShock.API.Errors; using OpenShock.API.Models.Requests; @@ -30,6 +31,7 @@ public sealed partial class IntegrationsController /// 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) diff --git a/Common/Services/BatchUpdate/BatchUpdateService.cs b/Common/Services/BatchUpdate/BatchUpdateService.cs index 90e14a05..cdf096ca 100644 --- a/Common/Services/BatchUpdate/BatchUpdateService.cs +++ b/Common/Services/BatchUpdate/BatchUpdateService.cs @@ -93,6 +93,12 @@ public sealed class BatchUpdateService : BackgroundService, IBatchUpdateService /// 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) { _dbFactory = dbFactory; @@ -100,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(); @@ -125,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 @@ -133,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)); } @@ -161,21 +167,21 @@ private void EnqueueRecordedControlStats() _controlStatBatches.Enqueue(new PendingControlStatBatch(Guid.CreateVersion7(), DateTime.UtcNow, deltas)); } - private async Task ApplyQueuedControlStatBatches() + private async Task ApplyQueuedControlStatBatches(CancellationToken cancellationToken = default) { - // A batch that throws 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. + // 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); + await ApplyControlStatBatch(batch, cancellationToken); _controlStatBatches.Dequeue(); } } - private async Task ApplyControlStatBatch(PendingControlStatBatch batch) + private async Task ApplyControlStatBatch(PendingControlStatBatch batch, CancellationToken cancellationToken = default) { - await using var db = await _dbFactory.CreateDbContextAsync(); - var result = await db.ApplyControlStatBatchAsync(batch.Id, batch.CreatedAt, batch.Deltas); + 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}", @@ -223,31 +229,41 @@ private async Task ApplyParkedControlStatBatches() } /// - /// Stats can't be rebuilt, so on shutdown whatever is unapplied keeps being offered to the database, then 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. + /// 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() + private async Task HandOffControlStatsOnShutdown(CancellationToken databaseToken) { EnqueueRecordedControlStats(); var started = Stopwatch.GetTimestamp(); while (_controlStatBatches.Count > 0) { - try + if (!databaseToken.IsCancellationRequested) { - await ApplyQueuedControlStatBatches(); - return; - } - catch (Exception e) - { - _logger.LogWarning(e, "Failed to apply {Count} control stat batches on shutdown", _controlStatBatches.Count); + 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(); - await _connectionMultiplexer.GetDatabase().ListRightPushAsync(ParkedControlStatBatchesKey, values); + // 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; @@ -257,8 +273,9 @@ private async Task HandOffControlStatsOnShutdown() _logger.LogWarning(e, "Failed to park {Count} control stat batches in Redis on shutdown", _controlStatBatches.Count); } - if (Stopwatch.GetElapsedTime(started) >= ShutdownHandOffTimeout) break; - await Task.Delay(TimeSpan.FromSeconds(1)); + 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; @@ -269,7 +286,7 @@ private async Task HandOffControlStatsOnShutdown() private sealed record PendingControlStatBatch(Guid Id, DateTime CreatedAt, ControlStatDelta[] Deltas); - private async Task UpdateSessions() + private async Task UpdateSessions(CancellationToken cancellationToken = default) { var sessionsToUpdate = new List>(); @@ -282,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"); @@ -331,17 +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(); - await HandOffControlStatsOnShutdown(); + // 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(), UpdateControlStats(), UpdateSessions()); + await Task.WhenAll(updates); } catch (Exception e) {