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.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/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/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..949796c0 --- /dev/null +++ b/API/Errors/ControlStatsError.cs @@ -0,0 +1,13 @@ +using System.Net; +using OpenShock.Common.Problems; + +using OpenShock.Internal.Common.Problems; + +namespace OpenShock.API.Errors; + +public static class ControlStatsError +{ + public static OpenShockProblem InvalidTimeZone => new("ControlStats.InvalidTimeZone", "Unknown time zone, expected an IANA time zone id like Europe/Berlin", HttpStatusCode.BadRequest); + public static OpenShockProblem MissingCapability => new("ControlStats.MissingCapability", "An automation token granting ControlStatsRead is required", HttpStatusCode.Forbidden); + public static OpenShockProblem InvalidRange => new("ControlStats.InvalidRange", "The from date must not be after the to date", HttpStatusCode.BadRequest); +} diff --git a/API/Models/Requests/ControlStatChangesRequest.cs b/API/Models/Requests/ControlStatChangesRequest.cs new file mode 100644 index 00000000..a11bcd2f --- /dev/null +++ b/API/Models/Requests/ControlStatChangesRequest.cs @@ -0,0 +1,16 @@ +using System.ComponentModel.DataAnnotations; + +namespace OpenShock.API.Models.Requests; + +public sealed class ControlStatChangesRequest +{ + /// The cursor returned by the previous call, omit for an initial sync of every requested user. + public DateTime? Since { get; init; } + + /// The users the caller is interested in, only these are ever reported. + [Required] + [MaxLength(MaxUserIds)] + public required Guid[] UserIds { get; init; } + + public const int MaxUserIds = 1000; +} diff --git a/API/Models/Response/ControlStatsResponse.cs b/API/Models/Response/ControlStatsResponse.cs new file mode 100644 index 00000000..1867d05e --- /dev/null +++ b/API/Models/Response/ControlStatsResponse.cs @@ -0,0 +1,90 @@ +using OpenShock.Common.Models; +using OpenShock.Common.OpenShockDb; + +namespace OpenShock.API.Models.Response; + +public sealed class ControlStatsResponse +{ + /// Activity on shockers you own. + public required ReceivedControlStats Received { get; init; } + + /// Activity you sent to shockers, your own included. + public required GivenControlStats Given { get; init; } +} + +public sealed class ReceivedControlStats +{ + public required ControlStatTotals[] Totals { get; init; } + public required ShockerControlStats[] Shockers { get; init; } + public required UserControlStats[] Controllers { get; init; } + public required DailyControlStats[] Days { get; init; } +} + +public sealed class GivenControlStats +{ + public required ControlStatTotals[] Totals { get; init; } + public required UserControlStats[] Owners { get; init; } +} + +/// Aggregated activity of one control type, split by whether it was live control. +public sealed class ControlStatTotals +{ + public required ControlType Type { get; init; } + public required bool Live { get; init; } + + /// Number of controls, for live control the number of continuous bursts. + public required long Count { get; init; } + + public required long DurationMs { get; init; } + + /// Average intensity weighted by duration. + public required double AverageIntensity { get; init; } + + public required byte MaxIntensity { get; init; } +} + +public sealed class ShockerControlStats +{ + /// Null for shockers that have since been deleted, these are combined into one entry. + public required Guid? ShockerId { get; init; } + public required string? ShockerName { get; init; } + public required ControlStatTotals[] Stats { get; init; } +} + +public sealed class UserControlStats +{ + /// Null for guests (public shares). + public required Guid? UserId { get; init; } + + /// Null for guests and deleted users. + public required BasicUserInfo? User { get; init; } + + public required ControlStatTotals[] Stats { get; init; } +} + +public sealed class DailyControlStats +{ + /// Day in the requested time zone. + public required DateOnly Date { get; init; } + public required ControlStatTotals[] Stats { get; init; } +} + +public sealed class ControlStatChangesResponse +{ + /// Pass as since on the next call. + public required DateTime Cursor { get; init; } + + public required UserControlStatTotals[] Users { get; init; } +} + +/// Lifetime totals of one user, the same views as without breakdowns. +public sealed class UserControlStatTotals +{ + public required Guid UserId { get; init; } + + /// Activity on shockers the user owns. + public required ControlStatTotals[] Received { get; init; } + + /// Activity the user sent to shockers, their own included. + public required ControlStatTotals[] Given { get; init; } +} diff --git a/Common.Tests/Services/ControlStatsAccumulatorTests.cs b/Common.Tests/Services/ControlStatsAccumulatorTests.cs new file mode 100644 index 00000000..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/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 a6e656a0..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 => "20260930204951_AddAutomationTokens"; + 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" }); @@ -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/Enums/AutomationTokenType.cs b/Common/OpenShockDb/Enums/AutomationTokenType.cs index 61cdd96f..b0f4fcda 100644 --- a/Common/OpenShockDb/Enums/AutomationTokenType.cs +++ b/Common/OpenShockDb/Enums/AutomationTokenType.cs @@ -4,11 +4,17 @@ namespace OpenShock.Common.OpenShockDb; /// -/// A protection that an automation token is allowed to switch off. +/// A protection that an automation token is allowed to switch off, or a service capability it grants. /// [PgEnum(Name = "automation_token_type")] public enum AutomationTokenType { [PgName("turnstile")] Turnstile = 0, [PgName("rate_limit")] RateLimit = 1, + + /// + /// Read control statistics of arbitrary users through the integration endpoints, used by first party + /// services like the Discord bot to keep profile widgets in sync. + /// + [PgName("control_stats_read")] ControlStatsRead = 2, } diff --git a/Common/OpenShockDb/Models/ControlStat.cs b/Common/OpenShockDb/Models/ControlStat.cs new file mode 100644 index 00000000..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