From 496a78e39a65302dab12b54fa528d669dd6681cf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?sondre=20Sj=C3=B8lyst?= <18713420+sondresjolyst@users.noreply.github.com> Date: Mon, 29 Jun 2026 16:41:47 +0200 Subject: [PATCH] feat: add per-user voltage color thresholds Stores optional warning/critical voltage thresholds per user and sensor, letting the client color a voltage reading. Thresholds are unset by default (no coloring until configured). - UserSensorVoltageThreshold entity (composite key UserId+SensorId), DbSet and fluent config mirroring UserSensorCustomName, with an EF migration. - PATCH/DELETE /sensors/{id}/voltage-thresholds, access-gated, validating that warning is above critical. - WarningVoltage/CriticalVoltage merged into SensorDto in GetAllSensors and GetSensor so the list carries them without an extra round-trip. - Cleared on unclaim and account deletion, and included in the data export. --- Controllers/SensorController.cs | 137 ++ Controllers/UserController.cs | 8 + Dtos/Sensor/SensorDto.cs | 8 + Dtos/Sensor/UpdateVoltageThresholdDto.cs | 13 + Dtos/Sensor/UserSensorVoltageThresholdDto.cs | 11 + ...AddUserSensorVoltageThresholds.Designer.cs | 2112 +++++++++++++++++ ...29140340_AddUserSensorVoltageThresholds.cs | 54 + .../ApplicationDbContextModelSnapshot.cs | 45 +- Models/ApplicationDbContext.cs | 14 + Models/Sensor/UserSensorVoltageThreshold.cs | 13 + 10 files changed, 2414 insertions(+), 1 deletion(-) create mode 100644 Dtos/Sensor/UpdateVoltageThresholdDto.cs create mode 100644 Dtos/Sensor/UserSensorVoltageThresholdDto.cs create mode 100644 Migrations/20260629140340_AddUserSensorVoltageThresholds.Designer.cs create mode 100644 Migrations/20260629140340_AddUserSensorVoltageThresholds.cs create mode 100644 Models/Sensor/UserSensorVoltageThreshold.cs diff --git a/Controllers/SensorController.cs b/Controllers/SensorController.cs index b4b551e..6c4e59a 100644 --- a/Controllers/SensorController.cs +++ b/Controllers/SensorController.cs @@ -149,6 +149,11 @@ public async Task GetAllSensors(CancellationToken ct = default) .Where(x => x.UserId == currentUserId) .ToDictionaryAsync(x => x.SensorId, x => x.CustomName, ct); + // Fetch all voltage color thresholds for the current user + var voltageThresholds = await _context.UserSensorVoltageThresholds + .Where(x => x.UserId == currentUserId) + .ToDictionaryAsync(x => x.SensorId, x => new { x.WarningVoltage, x.CriticalVoltage }, ct); + var sensorIds = sensors.Select(s => s.Id).ToList(); var suspendedIds = await CallerSuspendedSensorIdsAsync(sensorIds, ct); @@ -166,6 +171,11 @@ public async Task GetAllSensors(CancellationToken ct = default) var dto = _mapper.Map(sensor); if (customNames.TryGetValue(sensor.Id, out var customName)) dto.CustomName = customName; + if (voltageThresholds.TryGetValue(sensor.Id, out var threshold)) + { + dto.WarningVoltage = threshold.WarningVoltage; + dto.CriticalVoltage = threshold.CriticalVoltage; + } dto.Suspended = suspendedIds.Contains(sensor.Id); dto.Access = IsAdmin() ? DeviceAccess.Owner : (accessById.TryGetValue(sensor.Id, out var a) ? a : DeviceAccess.Owner); return dto; @@ -214,6 +224,16 @@ public async Task GetSensor(int id, CancellationToken ct = defaul if (!string.IsNullOrEmpty(customName)) dto.CustomName = customName; + var threshold = await _context.UserSensorVoltageThresholds + .Where(x => x.UserId == currentUserId && x.SensorId == id) + .Select(x => new { x.WarningVoltage, x.CriticalVoltage }) + .FirstOrDefaultAsync(ct); + if (threshold != null) + { + dto.WarningVoltage = threshold.WarningVoltage; + dto.CriticalVoltage = threshold.CriticalVoltage; + } + dto.Suspended = await IsSensorSuspendedForCallerAsync(id, ct); dto.Access = await CallerAccessAsync(id, ct); @@ -730,6 +750,9 @@ private async Task CleanUserSensorDataAsync(int sensorId, string userId) if (customName != null) _context.UserSensorCustomNames.Remove(customName); + _context.UserSensorVoltageThresholds.RemoveRange( + _context.UserSensorVoltageThresholds.Where(x => x.UserId == userId && x.SensorId == sensorId)); + _context.SensorActivities.RemoveRange(_context.SensorActivities.Where(a => a.UserId == userId && a.SensorId == sensorId)); _context.SensorPhotos.RemoveRange(_context.SensorPhotos.Where(p => p.UserId == userId && p.SensorId == sensorId)); _context.SensorOfflineNotifications.RemoveRange(_context.SensorOfflineNotifications.Where(n => n.UserId == userId && n.SensorId == sensorId)); @@ -1304,6 +1327,120 @@ public async Task UpdateCustomName( return Ok(resultDto); } + /// + /// Sets the caller's voltage color thresholds for a sensor. The reading is shown as a warning + /// below .WarningVoltage and as critical below CriticalVoltage. Upserts the row. + /// + /// The ID of the sensor. + /// The warning and critical voltages. Warning must be greater than critical. + /// The stored thresholds. + [HttpPatch("{sensorId}/voltage-thresholds")] + [SwaggerOperation(Summary = "Sets the caller's voltage color thresholds for a sensor.")] + [SwaggerResponse(200, "Thresholds updated.", typeof(UserSensorVoltageThresholdDto))] + [SwaggerResponse(400, "Invalid thresholds.")] + [SwaggerResponse(404, "Sensor not found.")] + [SwaggerResponse(403, "User does not have access to the sensor.")] + public async Task UpdateVoltageThresholds( + int sensorId, + [FromBody] UpdateVoltageThresholdDto dto) + { + _logger.LogInformation("UpdateVoltageThresholds called by {@LogData}", new { CallerUserId = User.UserId(), sensorId }); + + var sensor = await _context.Sensors.FindAsync(sensorId); + if (sensor == null) + { + _logger.LogWarning("UpdateVoltageThresholds not found: {@LogData}", new { sensorId }); + return NotFound(new { message = "Sensor not found!" }); + } + + if (!await UserCanAccessSensorAsync(sensor.Id)) + { + _logger.LogWarning("UpdateVoltageThresholds forbidden for {@LogData}", new { CallerUserId = User.UserId(), sensorId }); + return Forbid(); + } + + var currentUserId = User.UserId(); + if (string.IsNullOrEmpty(currentUserId)) + return Unauthorized(); + + if (dto.WarningVoltage <= dto.CriticalVoltage) + { + _logger.LogWarning("UpdateVoltageThresholds bad request: {@LogData}", new { sensorId, CallerUserId = currentUserId }); + return BadRequest(new { message = "WarningVoltage must be greater than CriticalVoltage." }); + } + + var entry = await _context.UserSensorVoltageThresholds + .FirstOrDefaultAsync(x => x.UserId == currentUserId && x.SensorId == sensorId); + + if (entry == null) + { + entry = new UserSensorVoltageThreshold + { + UserId = currentUserId, + SensorId = sensorId, + WarningVoltage = dto.WarningVoltage, + CriticalVoltage = dto.CriticalVoltage, + CreatedAt = DateTime.UtcNow + }; + _context.UserSensorVoltageThresholds.Add(entry); + } + else + { + entry.WarningVoltage = dto.WarningVoltage; + entry.CriticalVoltage = dto.CriticalVoltage; + } + + await _context.SaveChangesAsync(); + + var resultDto = new UserSensorVoltageThresholdDto + { + UserId = entry.UserId, + SensorId = entry.SensorId, + WarningVoltage = entry.WarningVoltage, + CriticalVoltage = entry.CriticalVoltage, + CreatedAt = entry.CreatedAt + }; + + _logger.LogInformation("Voltage thresholds updated for {@LogData}", new { sensorId, CallerUserId = currentUserId }); + return Ok(resultDto); + } + + /// + /// Clears the caller's voltage color thresholds for a sensor, so the reading is no longer colored. + /// + /// The ID of the sensor. + [HttpDelete("{sensorId}/voltage-thresholds")] + [SwaggerOperation(Summary = "Clears the caller's voltage color thresholds for a sensor.")] + [SwaggerResponse(204, "Thresholds cleared.")] + [SwaggerResponse(404, "Sensor not found.")] + [SwaggerResponse(403, "User does not have access to the sensor.")] + public async Task ClearVoltageThresholds(int sensorId) + { + _logger.LogInformation("ClearVoltageThresholds called by {@LogData}", new { CallerUserId = User.UserId(), sensorId }); + + var sensor = await _context.Sensors.FindAsync(sensorId); + if (sensor == null) + return NotFound(new { message = "Sensor not found!" }); + + if (!await UserCanAccessSensorAsync(sensor.Id)) + return Forbid(); + + var currentUserId = User.UserId(); + if (string.IsNullOrEmpty(currentUserId)) + return Unauthorized(); + + var entry = await _context.UserSensorVoltageThresholds + .FirstOrDefaultAsync(x => x.UserId == currentUserId && x.SensorId == sensorId); + if (entry != null) + { + _context.UserSensorVoltageThresholds.Remove(entry); + await _context.SaveChangesAsync(); + } + + _logger.LogInformation("Voltage thresholds cleared for {@LogData}", new { sensorId, CallerUserId = currentUserId }); + return NoContent(); + } + /// /// Retrieves the latest data point for a specific sensor. /// diff --git a/Controllers/UserController.cs b/Controllers/UserController.cs index 1124ea3..e25e39f 100644 --- a/Controllers/UserController.cs +++ b/Controllers/UserController.cs @@ -158,6 +158,7 @@ private async Task ClearUserOwnedRowsAsync(string userId) _context.RefreshTokens.RemoveRange(_context.RefreshTokens.Where(t => t.UserId == userId)); _context.UserSensorCustomNames.RemoveRange(_context.UserSensorCustomNames.Where(x => x.UserId == userId)); + _context.UserSensorVoltageThresholds.RemoveRange(_context.UserSensorVoltageThresholds.Where(x => x.UserId == userId)); _context.UserSwitchCustomNames.RemoveRange(_context.UserSwitchCustomNames.Where(x => x.UserId == userId)); _context.SensorActivities.RemoveRange(_context.SensorActivities.Where(a => a.UserId == userId)); _context.SensorPhotos.RemoveRange(_context.SensorPhotos.Where(p => p.UserId == userId)); @@ -267,6 +268,10 @@ public async Task ExportData(string id) .Where(x => x.UserId == id) .ToDictionaryAsync(x => x.SensorId, x => x.CustomName); + var voltageThresholds = await _context.UserSensorVoltageThresholds + .Where(x => x.UserId == id) + .ToDictionaryAsync(x => x.SensorId, x => new { x.WarningVoltage, x.CriticalVoltage }); + var sensors = await _context.UserSensors .Where(us => us.UserId == id) .Join(_context.Sensors, us => us.SensorId, s => s.Id, @@ -404,6 +409,9 @@ public async Task ExportData(string id) s.Type, DefaultName = s.DefaultName, CustomName = sensorCustomNames.TryGetValue(s.Id, out var cn) ? cn : null, + VoltageThresholds = voltageThresholds.TryGetValue(s.Id, out var vt) + ? new { vt.WarningVoltage, vt.CriticalVoltage } + : null, Readings = sensorReadings .Where(r => r.SensorId == s.Id) .Select(r => new { r.Value, r.Timestamp }), diff --git a/Dtos/Sensor/SensorDto.cs b/Dtos/Sensor/SensorDto.cs index 7f31d09..58300d0 100644 --- a/Dtos/Sensor/SensorDto.cs +++ b/Dtos/Sensor/SensorDto.cs @@ -13,6 +13,14 @@ public class SensorDto public required string DefaultName { get; set; } public required string ParentName { get; set; } + /// + /// The caller's voltage color thresholds for this sensor. Both are null when unset, in which + /// case the client leaves the reading uncolored. A reading at or above + /// is normal, below it is a warning, and below is critical. + /// + public double? WarningVoltage { get; set; } + public double? CriticalVoltage { get; set; } + /// True when the caller has this owned sensor turned off / over-quota suspended. Data reads are blocked while suspended. public bool Suspended { get; set; } diff --git a/Dtos/Sensor/UpdateVoltageThresholdDto.cs b/Dtos/Sensor/UpdateVoltageThresholdDto.cs new file mode 100644 index 0000000..98e32c5 --- /dev/null +++ b/Dtos/Sensor/UpdateVoltageThresholdDto.cs @@ -0,0 +1,13 @@ +using System.ComponentModel.DataAnnotations; + +namespace garge_api.Dtos.Sensor +{ + public class UpdateVoltageThresholdDto + { + [Range(0, 100)] + public required double WarningVoltage { get; set; } + + [Range(0, 100)] + public required double CriticalVoltage { get; set; } + } +} diff --git a/Dtos/Sensor/UserSensorVoltageThresholdDto.cs b/Dtos/Sensor/UserSensorVoltageThresholdDto.cs new file mode 100644 index 0000000..c4b95ba --- /dev/null +++ b/Dtos/Sensor/UserSensorVoltageThresholdDto.cs @@ -0,0 +1,11 @@ +namespace garge_api.Dtos.Sensor +{ + public class UserSensorVoltageThresholdDto + { + public required string UserId { get; set; } + public required int SensorId { get; set; } + public required double WarningVoltage { get; set; } + public required double CriticalVoltage { get; set; } + public required DateTime CreatedAt { get; set; } + } +} diff --git a/Migrations/20260629140340_AddUserSensorVoltageThresholds.Designer.cs b/Migrations/20260629140340_AddUserSensorVoltageThresholds.Designer.cs new file mode 100644 index 0000000..6acb659 --- /dev/null +++ b/Migrations/20260629140340_AddUserSensorVoltageThresholds.Designer.cs @@ -0,0 +1,2112 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using garge_api.Models; + +#nullable disable + +namespace garge_api.Migrations +{ + [DbContext(typeof(ApplicationDbContext))] + [Migration("20260629140340_AddUserSensorVoltageThresholds")] + partial class AddUserSensorVoltageThresholds + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.9") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Microsoft.AspNetCore.DataProtection.EntityFrameworkCore.DataProtectionKey", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("FriendlyName") + .HasColumnType("text"); + + b.Property("Xml") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.ToTable("DataProtectionKeys"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b => + { + b.Property("Id") + .HasColumnType("text"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("text"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex"); + + b.ToTable("AspNetRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("text"); + + b.Property("ClaimValue") + .HasColumnType("text"); + + b.Property("RoleId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetRoleClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("text"); + + b.Property("ClaimValue") + .HasColumnType("text"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("text"); + + b.Property("ProviderKey") + .HasColumnType("text"); + + b.Property("ProviderDisplayName") + .HasColumnType("text"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.Property("UserId") + .HasColumnType("text"); + + b.Property("RoleId") + .HasColumnType("text"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetUserRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("text"); + + b.Property("LoginProvider") + .HasColumnType("text"); + + b.Property("Name") + .HasColumnType("text"); + + b.Property("Value") + .HasColumnType("text"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("AspNetUserTokens", (string)null); + }); + + modelBuilder.Entity("User", b => + { + b.Property("Id") + .HasColumnType("text"); + + b.Property("AccessFailedCount") + .HasColumnType("integer"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DataRetentionOptOutAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("EmailConfirmed") + .HasColumnType("boolean"); + + b.Property("EmailVerificationCodeExpiration") + .HasColumnType("timestamp with time zone"); + + b.Property("EmailVerificationCodeHash") + .HasColumnType("text"); + + b.Property("FirstName") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("IsDeleted") + .HasColumnType("boolean"); + + b.Property("LastName") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("LockoutEnabled") + .HasColumnType("boolean"); + + b.Property("LockoutEnd") + .HasColumnType("timestamp with time zone"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("PasswordHash") + .HasColumnType("text"); + + b.Property("PasswordResetAttempts") + .HasColumnType("integer"); + + b.Property("PasswordResetCodeExpiration") + .HasColumnType("timestamp with time zone"); + + b.Property("PasswordResetCodeHash") + .HasColumnType("text"); + + b.Property("PhoneNumber") + .HasColumnType("text"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("boolean"); + + b.Property("SecurityStamp") + .HasColumnType("text"); + + b.Property("TermsAcceptedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("TermsAcceptedIp") + .HasMaxLength(45) + .HasColumnType("character varying(45)"); + + b.Property("TermsVersion") + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("TwoFactorEnabled") + .HasColumnType("boolean"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex"); + + b.ToTable("AspNetUsers", (string)null); + }); + + modelBuilder.Entity("UserProfile", b => + { + b.Property("Id") + .HasColumnType("text"); + + b.Property("OfflineAlertThresholdHours") + .HasColumnType("integer"); + + b.Property("PriceZone") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("character varying(10)"); + + b.Property("PushNotificationsEnabled") + .HasColumnType("boolean"); + + b.HasKey("Id"); + + b.ToTable("UserProfiles"); + }); + + modelBuilder.Entity("garge_api.Models.Admin.AppSettings", b => + { + b.Property("Id") + .HasColumnType("integer"); + + b.Property("CompanyAddress") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("CompanyEmail") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("CompanyLegalName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("CompanyName") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CompanyOrgNumber") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("CookieBannerEnabled") + .HasColumnType("boolean"); + + b.Property("VatEnabled") + .HasColumnType("boolean"); + + b.Property("VippsShopWebhookId") + .HasColumnType("text"); + + b.Property("VippsShopWebhookSecret") + .HasColumnType("text"); + + b.Property("VippsSubscriptionWebhookId") + .HasColumnType("text"); + + b.Property("VippsSubscriptionWebhookSecret") + .HasColumnType("text"); + + b.Property("VippsTestMode") + .HasColumnType("boolean"); + + b.HasKey("Id"); + + b.ToTable("AppSettings", t => + { + t.HasCheckConstraint("CK_AppSettings_SingleRow", "\"Id\" = 1"); + }); + + b.HasData( + new + { + Id = 1, + CompanyAddress = "Mårvegen 21a, 4347 Lye", + CompanyEmail = "sondresjoelyst@gmail.com", + CompanyLegalName = "Sjølyst Innovations", + CompanyName = "Garge", + CompanyOrgNumber = "934 531 035", + CookieBannerEnabled = true, + VatEnabled = false, + VippsTestMode = false + }); + }); + + modelBuilder.Entity("garge_api.Models.Admin.DailyStatSnapshot", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Date") + .HasColumnType("date"); + + b.Property("TotalAutomations") + .HasColumnType("integer"); + + b.Property("TotalSensors") + .HasColumnType("integer"); + + b.Property("TotalSwitches") + .HasColumnType("integer"); + + b.Property("TotalUsers") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("Date") + .IsUnique(); + + b.ToTable("DailyStatSnapshots"); + }); + + modelBuilder.Entity("garge_api.Models.Admin.RolePermission", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Permission") + .IsRequired() + .HasColumnType("text"); + + b.Property("RoleName") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("RoleName", "Permission") + .IsUnique(); + + b.ToTable("RolePermissions"); + }); + + modelBuilder.Entity("garge_api.Models.Anonymized.AnonymizedReading", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("SeriesId") + .HasColumnType("bigint"); + + b.Property("Timestamp") + .HasColumnType("timestamp with time zone"); + + b.Property("Value") + .HasColumnType("double precision"); + + b.HasKey("Id"); + + b.HasIndex("SeriesId", "Timestamp"); + + b.ToTable("AnonymizedReadings"); + }); + + modelBuilder.Entity("garge_api.Models.Anonymized.AnonymizedSeries", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AnonymizedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CalibrationOffsetV") + .HasColumnType("real"); + + b.Property("SourceType") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.HasKey("Id"); + + b.ToTable("AnonymizedSeries"); + }); + + modelBuilder.Entity("garge_api.Models.Auth.RefreshToken", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Created") + .HasColumnType("timestamp with time zone"); + + b.Property("Expires") + .HasColumnType("timestamp with time zone"); + + b.Property("Revoked") + .HasColumnType("timestamp with time zone"); + + b.Property("Token") + .IsRequired() + .HasColumnType("text"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("RefreshTokens"); + }); + + modelBuilder.Entity("garge_api.Models.Automation.AutomationRule", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Action") + .IsRequired() + .HasColumnType("text"); + + b.Property("Condition") + .IsRequired() + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ElectricityPriceArea") + .HasColumnType("text"); + + b.Property("ElectricityPriceCondition") + .HasColumnType("text"); + + b.Property("ElectricityPriceOperator") + .HasColumnType("text"); + + b.Property("ElectricityPriceThreshold") + .HasColumnType("double precision"); + + b.Property("IsEnabled") + .HasColumnType("boolean"); + + b.Property("LastTriggeredAt") + .HasColumnType("timestamp with time zone"); + + b.Property("SensorId") + .HasColumnType("integer"); + + b.Property("SensorType") + .IsRequired() + .HasColumnType("text"); + + b.Property("TargetId") + .HasColumnType("integer"); + + b.Property("TargetType") + .IsRequired() + .HasColumnType("text"); + + b.Property("Threshold") + .HasColumnType("double precision"); + + b.Property("TimerActivatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("TimerDurationHours") + .HasColumnType("double precision"); + + b.HasKey("Id"); + + b.HasIndex("TargetType", "TargetId", "SensorType", "SensorId", "Condition", "Threshold", "Action") + .IsUnique(); + + b.ToTable("AutomationRules"); + }); + + modelBuilder.Entity("garge_api.Models.Electricity.StoredElectricityPrice", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Area") + .IsRequired() + .HasColumnType("text"); + + b.Property("Currency") + .IsRequired() + .HasColumnType("text"); + + b.Property("DeliveryEnd") + .HasColumnType("timestamp with time zone"); + + b.Property("DeliveryStart") + .HasColumnType("timestamp with time zone"); + + b.Property("FetchedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Resolution") + .IsRequired() + .HasColumnType("text"); + + b.Property("Value") + .HasColumnType("double precision"); + + b.HasKey("Id"); + + b.HasIndex("Area", "Resolution", "DeliveryStart") + .IsUnique(); + + b.ToTable("StoredElectricityPrices"); + }); + + modelBuilder.Entity("garge_api.Models.Group.Group", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Icon") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.ToTable("Groups"); + }); + + modelBuilder.Entity("garge_api.Models.Group.GroupSensor", b => + { + b.Property("GroupId") + .HasColumnType("integer"); + + b.Property("SensorId") + .HasColumnType("integer"); + + b.HasKey("GroupId", "SensorId"); + + b.HasIndex("SensorId"); + + b.ToTable("GroupSensors"); + }); + + modelBuilder.Entity("garge_api.Models.Group.GroupSwitch", b => + { + b.Property("GroupId") + .HasColumnType("integer"); + + b.Property("SwitchId") + .HasColumnType("integer"); + + b.HasKey("GroupId", "SwitchId"); + + b.HasIndex("SwitchId"); + + b.ToTable("GroupSwitches"); + }); + + modelBuilder.Entity("garge_api.Models.Mqtt.DiscoveredDevice", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("DiscoveredBy") + .IsRequired() + .HasColumnType("text"); + + b.Property("Target") + .IsRequired() + .HasColumnType("text"); + + b.Property("Timestamp") + .HasColumnType("timestamp with time zone"); + + b.Property("Type") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("DiscoveredBy", "Target", "Type") + .IsUnique(); + + b.ToTable("DiscoveredDevices"); + }); + + modelBuilder.Entity("garge_api.Models.Mqtt.EMQXMqttAcl", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Action") + .IsRequired() + .HasColumnType("text"); + + b.Property("Permission") + .IsRequired() + .HasColumnType("text"); + + b.Property("Qos") + .HasColumnType("smallint"); + + b.Property("Retain") + .HasColumnType("smallint"); + + b.Property("Topic") + .IsRequired() + .HasColumnType("text"); + + b.Property("Username") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("Username", "Permission", "Action", "Topic", "Qos", "Retain") + .IsUnique(); + + b.ToTable("EMQXMqttAcls"); + }); + + modelBuilder.Entity("garge_api.Models.Mqtt.EMQXMqttUser", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("IsSuperuser") + .HasColumnType("boolean"); + + b.Property("PasswordHash") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Salt") + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.Property("Username") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("Username") + .IsUnique(); + + b.ToTable("EMQXMqttUsers"); + }); + + modelBuilder.Entity("garge_api.Models.Push.PushSubscription", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Auth") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Endpoint") + .IsRequired() + .HasMaxLength(2048) + .HasColumnType("character varying(2048)"); + + b.Property("P256dh") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "Endpoint") + .IsUnique(); + + b.ToTable("PushSubscriptions"); + }); + + modelBuilder.Entity("garge_api.Models.Push.SensorOfflineNotification", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("NotifiedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ResolvedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("SensorId") + .HasColumnType("integer"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "SensorId", "ResolvedAt"); + + b.ToTable("SensorOfflineNotifications"); + }); + + modelBuilder.Entity("garge_api.Models.Sensor.BatteryChargeEvent", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("DurationMinutes") + .HasColumnType("integer"); + + b.Property("EndedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("PeakRatio") + .HasColumnType("real"); + + b.Property("PeakVoltage") + .HasColumnType("real"); + + b.Property("RestingAtTime") + .HasColumnType("real"); + + b.Property("SensorId") + .HasColumnType("integer"); + + b.Property("StartedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("SensorId", "StartedAt") + .IsUnique(); + + b.ToTable("BatteryChargeEvents"); + }); + + modelBuilder.Entity("garge_api.Models.Sensor.BatteryHealth", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ChargeAcceptanceRatio") + .HasColumnType("real"); + + b.Property("CurrentVoltage") + .HasColumnType("real"); + + b.Property("DailyDropPctPerWeek") + .HasColumnType("real"); + + b.Property("FullChargesLast30d") + .HasColumnType("integer"); + + b.Property("LastFullChargeAt") + .HasColumnType("timestamp with time zone"); + + b.Property("LastFullChargePeak") + .HasColumnType("real"); + + b.Property("OnChargerNow") + .HasColumnType("boolean"); + + b.Property("PeakResting") + .HasColumnType("real"); + + b.Property("RestingMedian") + .HasColumnType("real"); + + b.Property("SensorId") + .HasColumnType("integer"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("Timestamp") + .HasColumnType("timestamp with time zone"); + + b.Property("VoltageMin24h") + .HasColumnType("real"); + + b.HasKey("Id"); + + b.HasIndex("SensorId"); + + b.HasIndex("Timestamp"); + + b.ToTable("BatteryHealthData"); + }); + + modelBuilder.Entity("garge_api.Models.Sensor.Sensor", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DefaultName") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("ParentName") + .IsRequired() + .HasColumnType("text"); + + b.Property("RegistrationCode") + .IsRequired() + .HasColumnType("text"); + + b.Property("Role") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Type") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("Sensors"); + }); + + modelBuilder.Entity("garge_api.Models.Sensor.SensorActivity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ActivityDate") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Notes") + .HasColumnType("text"); + + b.Property("SensorId") + .HasColumnType("integer"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("ActivityDate"); + + b.HasIndex("SensorId"); + + b.ToTable("SensorActivities"); + }); + + modelBuilder.Entity("garge_api.Models.Sensor.SensorData", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("SensorId") + .HasColumnType("integer"); + + b.Property("Timestamp") + .HasColumnType("timestamp with time zone"); + + b.Property("Value") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("SensorId"); + + b.HasIndex("Timestamp"); + + b.HasIndex("SensorId", "Timestamp") + .HasDatabaseName("IX_SensorData_SensorId_Timestamp"); + + b.ToTable("SensorData"); + }); + + modelBuilder.Entity("garge_api.Models.Sensor.SensorOwnershipPeriod", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("EndedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("SensorId") + .HasColumnType("integer"); + + b.Property("StartedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("SensorId", "StartedAt"); + + b.HasIndex("UserId", "SensorId"); + + b.ToTable("SensorOwnershipPeriods"); + }); + + modelBuilder.Entity("garge_api.Models.Sensor.SensorPhoto", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ContentType") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Data") + .IsRequired() + .HasColumnType("text"); + + b.Property("SensorId") + .HasColumnType("integer"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("SensorId") + .IsUnique(); + + b.HasIndex("UserId"); + + b.ToTable("SensorPhotos"); + }); + + modelBuilder.Entity("garge_api.Models.Sensor.UserSensor", b => + { + b.Property("UserId") + .HasColumnType("text"); + + b.Property("SensorId") + .HasColumnType("integer"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("IsOwner") + .HasColumnType("boolean"); + + b.Property("Permission") + .HasColumnType("integer"); + + b.Property("SuspendedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("UserId", "SensorId"); + + b.HasIndex("SensorId"); + + b.ToTable("UserSensors"); + }); + + modelBuilder.Entity("garge_api.Models.Sensor.UserSensorCustomName", b => + { + b.Property("UserId") + .HasColumnType("text"); + + b.Property("SensorId") + .HasColumnType("integer"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CustomName") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("UserId", "SensorId"); + + b.HasIndex("SensorId"); + + b.ToTable("UserSensorCustomNames"); + }); + + modelBuilder.Entity("garge_api.Models.Sensor.UserSensorVoltageThreshold", b => + { + b.Property("UserId") + .HasColumnType("text"); + + b.Property("SensorId") + .HasColumnType("integer"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CriticalVoltage") + .HasColumnType("double precision"); + + b.Property("WarningVoltage") + .HasColumnType("double precision"); + + b.HasKey("UserId", "SensorId"); + + b.HasIndex("SensorId"); + + b.ToTable("UserSensorVoltageThresholds"); + }); + + modelBuilder.Entity("garge_api.Models.Shop.Invoice", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AmountInOre") + .HasColumnType("integer"); + + b.Property("IssuedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("OrderId") + .HasColumnType("integer"); + + b.Property("PdfData") + .IsRequired() + .HasColumnType("bytea"); + + b.Property("SubscriptionId") + .HasColumnType("integer"); + + b.Property("VippsChargeId") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.HasKey("Id"); + + b.HasIndex("OrderId") + .IsUnique() + .HasFilter("\"OrderId\" IS NOT NULL"); + + b.HasIndex("SubscriptionId"); + + b.HasIndex("VippsChargeId") + .IsUnique() + .HasFilter("\"VippsChargeId\" IS NOT NULL"); + + b.ToTable("Invoices"); + }); + + modelBuilder.Entity("garge_api.Models.Shop.Order", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("IsTest") + .HasColumnType("boolean"); + + b.Property("ShippedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ShippingAddress") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("Status") + .HasColumnType("integer"); + + b.Property("TotalInOre") + .HasColumnType("integer"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("text"); + + b.Property("VippsOrderId") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.HasIndex("VippsOrderId") + .IsUnique() + .HasFilter("\"VippsOrderId\" IS NOT NULL"); + + b.ToTable("Orders"); + }); + + modelBuilder.Entity("garge_api.Models.Shop.OrderItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("OrderId") + .HasColumnType("integer"); + + b.Property("PriceAtPurchaseInOre") + .HasColumnType("integer"); + + b.Property("Quantity") + .HasColumnType("integer"); + + b.Property("ShopItemId") + .HasColumnType("integer"); + + b.Property("UnitPriceExclVatInOre") + .HasColumnType("integer"); + + b.Property("VatPercentage") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("OrderId"); + + b.HasIndex("ShopItemId"); + + b.ToTable("OrderItems"); + }); + + modelBuilder.Entity("garge_api.Models.Shop.ShopItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("PriceInOre") + .HasColumnType("integer"); + + b.Property("StockCount") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("IsActive"); + + b.ToTable("ShopItems"); + }); + + modelBuilder.Entity("garge_api.Models.Shop.ShopItemPhoto", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ContentType") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Data") + .IsRequired() + .HasColumnType("text"); + + b.Property("ShopItemId") + .HasColumnType("integer"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("ShopItemId") + .IsUnique(); + + b.HasIndex("UserId"); + + b.ToTable("ShopItemPhotos"); + }); + + modelBuilder.Entity("garge_api.Models.Subscription.Product", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.Property("Interval") + .HasColumnType("integer"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("PriceInOre") + .HasColumnType("integer"); + + b.Property("Type") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("IsActive"); + + b.ToTable("Products"); + }); + + modelBuilder.Entity("garge_api.Models.Subscription.Subscription", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("BillingAddress") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("ConsentAcceptedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ConsentIp") + .HasMaxLength(45) + .HasColumnType("character varying(45)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("IsTest") + .HasColumnType("boolean"); + + b.Property("NextChargeDate") + .HasColumnType("timestamp with time zone"); + + b.Property("ProductId") + .HasColumnType("integer"); + + b.Property("Quantity") + .HasColumnType("integer"); + + b.Property("StartDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Status") + .HasColumnType("integer"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("text"); + + b.Property("VippsAgreementId") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("VippsConfirmationUrl") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("ProductId"); + + b.HasIndex("VippsAgreementId") + .IsUnique(); + + b.HasIndex("UserId", "Status"); + + b.ToTable("Subscriptions"); + }); + + modelBuilder.Entity("garge_api.Models.Switch.Switch", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("RegistrationCode") + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("Role") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Type") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.HasKey("Id"); + + b.ToTable("Switches"); + }); + + modelBuilder.Entity("garge_api.Models.Switch.SwitchData", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("SwitchId") + .HasColumnType("integer"); + + b.Property("Timestamp") + .HasColumnType("timestamp with time zone"); + + b.Property("Value") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("SwitchId"); + + b.ToTable("SwitchData", (string)null); + }); + + modelBuilder.Entity("garge_api.Models.Switch.SwitchOwnershipPeriod", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("EndedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("StartedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("SwitchId") + .HasColumnType("integer"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("SwitchId", "StartedAt"); + + b.HasIndex("UserId", "SwitchId"); + + b.ToTable("SwitchOwnershipPeriods"); + }); + + modelBuilder.Entity("garge_api.Models.Switch.UserSwitch", b => + { + b.Property("UserId") + .HasColumnType("text"); + + b.Property("SwitchId") + .HasColumnType("integer"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("IsOwner") + .HasColumnType("boolean"); + + b.Property("Permission") + .HasColumnType("integer"); + + b.Property("SuspendedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("UserId", "SwitchId"); + + b.HasIndex("SwitchId"); + + b.ToTable("UserSwitches"); + }); + + modelBuilder.Entity("garge_api.Models.Switch.UserSwitchCustomName", b => + { + b.Property("UserId") + .HasColumnType("text"); + + b.Property("SwitchId") + .HasColumnType("integer"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CustomName") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("UserId", "SwitchId"); + + b.HasIndex("SwitchId"); + + b.ToTable("UserSwitchCustomNames"); + }); + + modelBuilder.Entity("garge_api.Models.Webhook.ProcessedWebhookEvent", b => + { + b.Property("Id") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("ProcessedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Source") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.HasKey("Id"); + + b.HasIndex("ProcessedAt"); + + b.HasIndex("Source", "Id"); + + b.ToTable("ProcessedWebhookEvents"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("UserProfile", b => + { + b.HasOne("User", "User") + .WithOne() + .HasForeignKey("UserProfile", "Id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("garge_api.Models.Anonymized.AnonymizedReading", b => + { + b.HasOne("garge_api.Models.Anonymized.AnonymizedSeries", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("garge_api.Models.Auth.RefreshToken", b => + { + b.HasOne("User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("garge_api.Models.Group.GroupSensor", b => + { + b.HasOne("garge_api.Models.Group.Group", "Group") + .WithMany("GroupSensors") + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("garge_api.Models.Sensor.Sensor", "Sensor") + .WithMany() + .HasForeignKey("SensorId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Group"); + + b.Navigation("Sensor"); + }); + + modelBuilder.Entity("garge_api.Models.Group.GroupSwitch", b => + { + b.HasOne("garge_api.Models.Group.Group", "Group") + .WithMany("GroupSwitches") + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("garge_api.Models.Switch.Switch", "Switch") + .WithMany() + .HasForeignKey("SwitchId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Group"); + + b.Navigation("Switch"); + }); + + modelBuilder.Entity("garge_api.Models.Push.PushSubscription", b => + { + b.HasOne("User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("garge_api.Models.Sensor.BatteryChargeEvent", b => + { + b.HasOne("garge_api.Models.Sensor.Sensor", "Sensor") + .WithMany() + .HasForeignKey("SensorId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Sensor"); + }); + + modelBuilder.Entity("garge_api.Models.Sensor.BatteryHealth", b => + { + b.HasOne("garge_api.Models.Sensor.Sensor", "Sensor") + .WithMany() + .HasForeignKey("SensorId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Sensor"); + }); + + modelBuilder.Entity("garge_api.Models.Sensor.SensorActivity", b => + { + b.HasOne("garge_api.Models.Sensor.Sensor", "Sensor") + .WithMany() + .HasForeignKey("SensorId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Sensor"); + }); + + modelBuilder.Entity("garge_api.Models.Sensor.SensorData", b => + { + b.HasOne("garge_api.Models.Sensor.Sensor", "Sensor") + .WithMany() + .HasForeignKey("SensorId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Sensor"); + }); + + modelBuilder.Entity("garge_api.Models.Sensor.SensorOwnershipPeriod", b => + { + b.HasOne("garge_api.Models.Sensor.Sensor", "Sensor") + .WithMany() + .HasForeignKey("SensorId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Sensor"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("garge_api.Models.Sensor.SensorPhoto", b => + { + b.HasOne("garge_api.Models.Sensor.Sensor", "Sensor") + .WithMany() + .HasForeignKey("SensorId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Sensor"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("garge_api.Models.Sensor.UserSensor", b => + { + b.HasOne("garge_api.Models.Sensor.Sensor", "Sensor") + .WithMany() + .HasForeignKey("SensorId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Sensor"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("garge_api.Models.Sensor.UserSensorCustomName", b => + { + b.HasOne("garge_api.Models.Sensor.Sensor", "Sensor") + .WithMany() + .HasForeignKey("SensorId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Sensor"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("garge_api.Models.Sensor.UserSensorVoltageThreshold", b => + { + b.HasOne("garge_api.Models.Sensor.Sensor", "Sensor") + .WithMany() + .HasForeignKey("SensorId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Sensor"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("garge_api.Models.Shop.Invoice", b => + { + b.HasOne("garge_api.Models.Shop.Order", "Order") + .WithOne("Invoice") + .HasForeignKey("garge_api.Models.Shop.Invoice", "OrderId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("garge_api.Models.Subscription.Subscription", "Subscription") + .WithMany() + .HasForeignKey("SubscriptionId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("Order"); + + b.Navigation("Subscription"); + }); + + modelBuilder.Entity("garge_api.Models.Shop.Order", b => + { + b.HasOne("User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("garge_api.Models.Shop.OrderItem", b => + { + b.HasOne("garge_api.Models.Shop.Order", "Order") + .WithMany("OrderItems") + .HasForeignKey("OrderId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("garge_api.Models.Shop.ShopItem", "ShopItem") + .WithMany() + .HasForeignKey("ShopItemId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Order"); + + b.Navigation("ShopItem"); + }); + + modelBuilder.Entity("garge_api.Models.Shop.ShopItemPhoto", b => + { + b.HasOne("garge_api.Models.Shop.ShopItem", "ShopItem") + .WithMany() + .HasForeignKey("ShopItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("ShopItem"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("garge_api.Models.Subscription.Subscription", b => + { + b.HasOne("garge_api.Models.Subscription.Product", "Product") + .WithMany() + .HasForeignKey("ProductId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Product"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("garge_api.Models.Switch.SwitchData", b => + { + b.HasOne("garge_api.Models.Switch.Switch", "Switch") + .WithMany() + .HasForeignKey("SwitchId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Switch"); + }); + + modelBuilder.Entity("garge_api.Models.Switch.SwitchOwnershipPeriod", b => + { + b.HasOne("garge_api.Models.Switch.Switch", "Switch") + .WithMany() + .HasForeignKey("SwitchId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Switch"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("garge_api.Models.Switch.UserSwitch", b => + { + b.HasOne("garge_api.Models.Switch.Switch", "Switch") + .WithMany() + .HasForeignKey("SwitchId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Switch"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("garge_api.Models.Switch.UserSwitchCustomName", b => + { + b.HasOne("garge_api.Models.Switch.Switch", "Switch") + .WithMany() + .HasForeignKey("SwitchId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Switch"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("garge_api.Models.Group.Group", b => + { + b.Navigation("GroupSensors"); + + b.Navigation("GroupSwitches"); + }); + + modelBuilder.Entity("garge_api.Models.Shop.Order", b => + { + b.Navigation("Invoice"); + + b.Navigation("OrderItems"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/Migrations/20260629140340_AddUserSensorVoltageThresholds.cs b/Migrations/20260629140340_AddUserSensorVoltageThresholds.cs new file mode 100644 index 0000000..de5e0aa --- /dev/null +++ b/Migrations/20260629140340_AddUserSensorVoltageThresholds.cs @@ -0,0 +1,54 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace garge_api.Migrations +{ + /// + public partial class AddUserSensorVoltageThresholds : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "UserSensorVoltageThresholds", + columns: table => new + { + UserId = table.Column(type: "text", nullable: false), + SensorId = table.Column(type: "integer", nullable: false), + WarningVoltage = table.Column(type: "double precision", nullable: false), + CriticalVoltage = table.Column(type: "double precision", nullable: false), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_UserSensorVoltageThresholds", x => new { x.UserId, x.SensorId }); + table.ForeignKey( + name: "FK_UserSensorVoltageThresholds_AspNetUsers_UserId", + column: x => x.UserId, + principalTable: "AspNetUsers", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "FK_UserSensorVoltageThresholds_Sensors_SensorId", + column: x => x.SensorId, + principalTable: "Sensors", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_UserSensorVoltageThresholds_SensorId", + table: "UserSensorVoltageThresholds", + column: "SensorId"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "UserSensorVoltageThresholds"); + } + } +} diff --git a/Migrations/ApplicationDbContextModelSnapshot.cs b/Migrations/ApplicationDbContextModelSnapshot.cs index 1b8b261..15f6bad 100644 --- a/Migrations/ApplicationDbContextModelSnapshot.cs +++ b/Migrations/ApplicationDbContextModelSnapshot.cs @@ -17,7 +17,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) { #pragma warning disable 612, 618 modelBuilder - .HasAnnotation("ProductVersion", "10.0.7") + .HasAnnotation("ProductVersion", "10.0.9") .HasAnnotation("Relational:MaxIdentifierLength", 63); NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); @@ -1173,6 +1173,30 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.ToTable("UserSensorCustomNames"); }); + modelBuilder.Entity("garge_api.Models.Sensor.UserSensorVoltageThreshold", b => + { + b.Property("UserId") + .HasColumnType("text"); + + b.Property("SensorId") + .HasColumnType("integer"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CriticalVoltage") + .HasColumnType("double precision"); + + b.Property("WarningVoltage") + .HasColumnType("double precision"); + + b.HasKey("UserId", "SensorId"); + + b.HasIndex("SensorId"); + + b.ToTable("UserSensorVoltageThresholds"); + }); + modelBuilder.Entity("garge_api.Models.Shop.Invoice", b => { b.Property("Id") @@ -1894,6 +1918,25 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Navigation("User"); }); + modelBuilder.Entity("garge_api.Models.Sensor.UserSensorVoltageThreshold", b => + { + b.HasOne("garge_api.Models.Sensor.Sensor", "Sensor") + .WithMany() + .HasForeignKey("SensorId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Sensor"); + + b.Navigation("User"); + }); + modelBuilder.Entity("garge_api.Models.Shop.Invoice", b => { b.HasOne("garge_api.Models.Shop.Order", "Order") diff --git a/Models/ApplicationDbContext.cs b/Models/ApplicationDbContext.cs index d98872a..ecbc218 100644 --- a/Models/ApplicationDbContext.cs +++ b/Models/ApplicationDbContext.cs @@ -31,6 +31,7 @@ public partial class ApplicationDbContext(DbContextOptions public DbSet SwitchData { get; set; } public DbSet RefreshTokens { get; set; } public DbSet UserSensorCustomNames { get; set; } + public DbSet UserSensorVoltageThresholds { get; set; } public DbSet SensorActivities { get; set; } public DbSet EMQXMqttUsers { get; set; } public DbSet EMQXMqttAcls { get; set; } @@ -118,6 +119,19 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) .WithMany() .HasForeignKey(x => x.SensorId); + modelBuilder.Entity() + .HasKey(x => new { x.UserId, x.SensorId }); + + modelBuilder.Entity() + .HasOne(x => x.User) + .WithMany() + .HasForeignKey(x => x.UserId); + + modelBuilder.Entity() + .HasOne(x => x.Sensor) + .WithMany() + .HasForeignKey(x => x.SensorId); + modelBuilder.Entity() .HasOne(sa => sa.Sensor) .WithMany() diff --git a/Models/Sensor/UserSensorVoltageThreshold.cs b/Models/Sensor/UserSensorVoltageThreshold.cs new file mode 100644 index 0000000..5993978 --- /dev/null +++ b/Models/Sensor/UserSensorVoltageThreshold.cs @@ -0,0 +1,13 @@ +namespace garge_api.Models.Sensor +{ + public class UserSensorVoltageThreshold + { + public string UserId { get; set; } = default!; + public int SensorId { get; set; } + public double WarningVoltage { get; set; } + public double CriticalVoltage { get; set; } + public DateTime CreatedAt { get; set; } + public User User { get; set; } = default!; + public Sensor Sensor { get; set; } = default!; + } +}