diff --git a/Migrations/20260419174727_DeduplicateAndNormalizeElectricityPrices.Designer.cs b/Migrations/20260419174727_DeduplicateAndNormalizeElectricityPrices.Designer.cs
new file mode 100644
index 0000000..386d4b0
--- /dev/null
+++ b/Migrations/20260419174727_DeduplicateAndNormalizeElectricityPrices.Designer.cs
@@ -0,0 +1,1097 @@
+//
+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("20260419174727_DeduplicateAndNormalizeElectricityPrices")]
+ partial class DeduplicateAndNormalizeElectricityPrices
+ {
+ ///
+ protected override void BuildTargetModel(ModelBuilder modelBuilder)
+ {
+#pragma warning disable 612, 618
+ modelBuilder
+ .HasAnnotation("ProductVersion", "9.0.14")
+ .HasAnnotation("Relational:MaxIdentifierLength", 63);
+
+ NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
+
+ 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("Email")
+ .HasMaxLength(256)
+ .HasColumnType("character varying(256)");
+
+ b.Property("EmailConfirmed")
+ .HasColumnType("boolean");
+
+ b.Property("EmailVerificationCode")
+ .HasColumnType("text");
+
+ b.Property("EmailVerificationCodeExpiration")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("FirstName")
+ .IsRequired()
+ .HasMaxLength(50)
+ .HasColumnType("character varying(50)");
+
+ 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("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("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("Email")
+ .IsRequired()
+ .HasMaxLength(50)
+ .HasColumnType("character varying(50)");
+
+ b.Property("FirstName")
+ .IsRequired()
+ .HasMaxLength(50)
+ .HasColumnType("character varying(50)");
+
+ b.Property("LastName")
+ .IsRequired()
+ .HasMaxLength(50)
+ .HasColumnType("character varying(50)");
+
+ b.Property("PriceZone")
+ .IsRequired()
+ .HasMaxLength(10)
+ .HasColumnType("character varying(10)");
+
+ b.HasKey("Id");
+
+ b.ToTable("UserProfiles");
+ });
+
+ 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.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("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.Sensor.BatteryHealth", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id"));
+
+ b.Property("Baseline")
+ .HasColumnType("real");
+
+ b.Property("ChargesRecorded")
+ .HasColumnType("integer");
+
+ b.Property("DropPct")
+ .HasColumnType("real");
+
+ b.Property("LastCharge")
+ .HasColumnType("real");
+
+ b.Property("LastChargedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("SensorId")
+ .HasColumnType("integer");
+
+ b.Property("Status")
+ .IsRequired()
+ .HasMaxLength(20)
+ .HasColumnType("character varying(20)");
+
+ b.Property("Timestamp")
+ .HasColumnType("timestamp with time zone");
+
+ 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("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.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.UserSensor", b =>
+ {
+ b.Property("UserId")
+ .HasColumnType("text");
+
+ b.Property("SensorId")
+ .HasColumnType("integer");
+
+ b.Property("CreatedAt")
+ .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.Switch.Switch", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id"));
+
+ 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.UserSwitch", b =>
+ {
+ b.Property("UserId")
+ .HasColumnType("text");
+
+ b.Property("SwitchId")
+ .HasColumnType("integer");
+
+ b.Property("CreatedAt")
+ .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.WebhookSubscription", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id"));
+
+ b.Property("CreatedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("WebhookUrl")
+ .IsRequired()
+ .HasMaxLength(500)
+ .HasColumnType("character varying(500)");
+
+ b.HasKey("Id");
+
+ b.ToTable("WebhookSubscriptions");
+ });
+
+ 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.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.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.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.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.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.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");
+ });
+#pragma warning restore 612, 618
+ }
+ }
+}
diff --git a/Migrations/20260419174727_DeduplicateAndNormalizeElectricityPrices.cs b/Migrations/20260419174727_DeduplicateAndNormalizeElectricityPrices.cs
new file mode 100644
index 0000000..73938f1
--- /dev/null
+++ b/Migrations/20260419174727_DeduplicateAndNormalizeElectricityPrices.cs
@@ -0,0 +1,72 @@
+using Microsoft.EntityFrameworkCore.Migrations;
+
+#nullable disable
+
+namespace garge_api.Migrations
+{
+ ///
+ public partial class DeduplicateAndNormalizeElectricityPrices : Migration
+ {
+ ///
+ protected override void Up(MigrationBuilder migrationBuilder)
+ {
+ // Step 1: Drop the unique index so we can manipulate duplicate rows freely
+ migrationBuilder.DropIndex(
+ name: "IX_StoredElectricityPrices_Area_Resolution_DeliveryStart",
+ table: "StoredElectricityPrices");
+
+ // Step 2: For DAILY and MONTHLY rows, normalize DeliveryStart and DeliveryEnd to
+ // UTC midnight of their calendar date (DATE_TRUNC('day', ...) AT TIME ZONE 'UTC').
+ // This makes rows created on a UTC+2 machine (22:00 prev day) and rows created on a
+ // UTC Docker container (00:00) converge to the same timestamp.
+ migrationBuilder.Sql(@"
+ UPDATE ""StoredElectricityPrices""
+ SET
+ ""DeliveryStart"" = DATE_TRUNC('day', ""DeliveryStart"" AT TIME ZONE 'UTC') AT TIME ZONE 'UTC',
+ ""DeliveryEnd"" = DATE_TRUNC('day', ""DeliveryEnd"" AT TIME ZONE 'UTC') AT TIME ZONE 'UTC'
+ WHERE ""Resolution"" IN ('DAILY', 'MONTHLY');
+ ");
+
+ // Step 3: Delete duplicate rows keeping the one with the latest FetchedAt per
+ // (Area, Resolution, DeliveryStart) group.
+ migrationBuilder.Sql(@"
+ DELETE FROM ""StoredElectricityPrices""
+ WHERE ""Id"" IN (
+ SELECT ""Id""
+ FROM (
+ SELECT
+ ""Id"",
+ ROW_NUMBER() OVER (
+ PARTITION BY ""Area"", ""Resolution"", ""DeliveryStart""
+ ORDER BY ""FetchedAt"" DESC
+ ) AS rn
+ FROM ""StoredElectricityPrices""
+ ) ranked
+ WHERE rn > 1
+ );
+ ");
+
+ // Step 4: Recreate the unique index now that duplicates are gone
+ migrationBuilder.CreateIndex(
+ name: "IX_StoredElectricityPrices_Area_Resolution_DeliveryStart",
+ table: "StoredElectricityPrices",
+ columns: new[] { "Area", "Resolution", "DeliveryStart" },
+ unique: true);
+ }
+
+ ///
+ protected override void Down(MigrationBuilder migrationBuilder)
+ {
+ // Normalization is non-reversible; we can only restore the index
+ migrationBuilder.DropIndex(
+ name: "IX_StoredElectricityPrices_Area_Resolution_DeliveryStart",
+ table: "StoredElectricityPrices");
+
+ migrationBuilder.CreateIndex(
+ name: "IX_StoredElectricityPrices_Area_Resolution_DeliveryStart",
+ table: "StoredElectricityPrices",
+ columns: new[] { "Area", "Resolution", "DeliveryStart" },
+ unique: true);
+ }
+ }
+}
diff --git a/Migrations/20260419180841_FixElectricityPriceNormalization.Designer.cs b/Migrations/20260419180841_FixElectricityPriceNormalization.Designer.cs
new file mode 100644
index 0000000..fc4fdfd
--- /dev/null
+++ b/Migrations/20260419180841_FixElectricityPriceNormalization.Designer.cs
@@ -0,0 +1,1097 @@
+//
+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("20260419180841_FixElectricityPriceNormalization")]
+ partial class FixElectricityPriceNormalization
+ {
+ ///
+ protected override void BuildTargetModel(ModelBuilder modelBuilder)
+ {
+#pragma warning disable 612, 618
+ modelBuilder
+ .HasAnnotation("ProductVersion", "9.0.14")
+ .HasAnnotation("Relational:MaxIdentifierLength", 63);
+
+ NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
+
+ 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("Email")
+ .HasMaxLength(256)
+ .HasColumnType("character varying(256)");
+
+ b.Property("EmailConfirmed")
+ .HasColumnType("boolean");
+
+ b.Property("EmailVerificationCode")
+ .HasColumnType("text");
+
+ b.Property("EmailVerificationCodeExpiration")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("FirstName")
+ .IsRequired()
+ .HasMaxLength(50)
+ .HasColumnType("character varying(50)");
+
+ 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("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("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("Email")
+ .IsRequired()
+ .HasMaxLength(50)
+ .HasColumnType("character varying(50)");
+
+ b.Property("FirstName")
+ .IsRequired()
+ .HasMaxLength(50)
+ .HasColumnType("character varying(50)");
+
+ b.Property("LastName")
+ .IsRequired()
+ .HasMaxLength(50)
+ .HasColumnType("character varying(50)");
+
+ b.Property("PriceZone")
+ .IsRequired()
+ .HasMaxLength(10)
+ .HasColumnType("character varying(10)");
+
+ b.HasKey("Id");
+
+ b.ToTable("UserProfiles");
+ });
+
+ 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.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("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