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("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/20260419180841_FixElectricityPriceNormalization.cs b/Migrations/20260419180841_FixElectricityPriceNormalization.cs new file mode 100644 index 0000000..5fa5844 --- /dev/null +++ b/Migrations/20260419180841_FixElectricityPriceNormalization.cs @@ -0,0 +1,71 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace garge_api.Migrations +{ + /// + public partial class FixElectricityPriceNormalization : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + // The previous migration (DeduplicateAndNormalizeElectricityPrices) used + // DATE_TRUNC('day', DeliveryStart AT TIME ZONE 'UTC') which floors to UTC midnight. + // This produced wrong dates for Norway-local timestamps: e.g. Feb 1 stored as + // 2026-01-31T23:00:00Z (UTC+1) was truncated to 2026-01-31 instead of 2026-02-01. + // + // Fix: add 12 hours before truncating to round to the nearest UTC day, which + // correctly handles UTC offsets up to ±12h (Norway is UTC+1/+2). + + migrationBuilder.DropIndex( + name: "IX_StoredElectricityPrices_Area_Resolution_DeliveryStart", + table: "StoredElectricityPrices"); + + migrationBuilder.Sql(@" + UPDATE ""StoredElectricityPrices"" + SET + ""DeliveryStart"" = DATE_TRUNC('day', (""DeliveryStart"" + INTERVAL '12 hours') AT TIME ZONE 'UTC') AT TIME ZONE 'UTC', + ""DeliveryEnd"" = DATE_TRUNC('day', (""DeliveryEnd"" + INTERVAL '12 hours') AT TIME ZONE 'UTC') AT TIME ZONE 'UTC' + WHERE ""Resolution"" IN ('DAILY', 'MONTHLY'); + "); + + 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 + ); + "); + + migrationBuilder.CreateIndex( + name: "IX_StoredElectricityPrices_Area_Resolution_DeliveryStart", + table: "StoredElectricityPrices", + columns: new[] { "Area", "Resolution", "DeliveryStart" }, + unique: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + 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/20260419185052_CleanupBadElectricityPriceRows.Designer.cs b/Migrations/20260419185052_CleanupBadElectricityPriceRows.Designer.cs new file mode 100644 index 0000000..e183ef9 --- /dev/null +++ b/Migrations/20260419185052_CleanupBadElectricityPriceRows.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("20260419185052_CleanupBadElectricityPriceRows")] + partial class CleanupBadElectricityPriceRows + { + /// + 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/20260419185052_CleanupBadElectricityPriceRows.cs b/Migrations/20260419185052_CleanupBadElectricityPriceRows.cs new file mode 100644 index 0000000..a1f535b --- /dev/null +++ b/Migrations/20260419185052_CleanupBadElectricityPriceRows.cs @@ -0,0 +1,42 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace garge_api.Migrations +{ + /// + public partial class CleanupBadElectricityPriceRows : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + // The FixElectricityPriceNormalization migration applied DATE_TRUNC + 12h to round + // Norway-local timestamps. However, rows that were already at UTC midnight on the + // WRONG day (created by the prior DeduplicateAndNormalizeElectricityPrices migration) + // could not be fixed by adding 12h — 2026-01-31T00:00:00Z + 12h still truncates to + // 2026-01-31, not 2026-02-01. So those bad rows remain. + // + // MONTHLY entries from Nord Pool always have deliveryStart on the 1st of the month. + // Any row where the UTC day is not 1 is a wrong-date artifact. + migrationBuilder.Sql(@" + DELETE FROM ""StoredElectricityPrices"" + WHERE ""Resolution"" = 'MONTHLY' + AND EXTRACT(DAY FROM ""DeliveryStart"" AT TIME ZONE 'UTC') != 1; + "); + + // DAILY rows from a Norway-local run were shifted one day back by the prior + // normalization (e.g. Apr 11 became Apr 10). Delete all DAILY rows for a clean slate; + // they will be re-fetched correctly on the next service startup via FetchAllOnStartupAsync. + migrationBuilder.Sql(@" + DELETE FROM ""StoredElectricityPrices"" + WHERE ""Resolution"" = 'DAILY'; + "); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + // Deleted rows will be re-fetched by the background service; no rollback possible. + } + } +} diff --git a/Services/ElectricityPriceFetchService.cs b/Services/ElectricityPriceFetchService.cs index 54363fa..0bc6aef 100644 --- a/Services/ElectricityPriceFetchService.cs +++ b/Services/ElectricityPriceFetchService.cs @@ -103,7 +103,7 @@ private async Task FetchAndStoreAsync(string resolution, DateTime date, Cancella if (!Areas.Contains(area)) continue; var incomingStarts = areaPrices.Values - .Select(e => e.Start.ToUniversalTime()) + .Select(e => e.Start) .ToHashSet(); var existing = await db.StoredElectricityPrices @@ -112,8 +112,8 @@ private async Task FetchAndStoreAsync(string resolution, DateTime date, Cancella foreach (var entry in areaPrices.Values) { - var deliveryStart = entry.Start.ToUniversalTime(); - var deliveryEnd = entry.End.ToUniversalTime(); + var deliveryStart = entry.Start; + var deliveryEnd = entry.End; var valueKwh = (double)(entry.Value / 1000m); if (existing.TryGetValue(deliveryStart, out var row)) diff --git a/Services/NordPoolService.cs b/Services/NordPoolService.cs index f5fa2c6..424c741 100644 --- a/Services/NordPoolService.cs +++ b/Services/NordPoolService.cs @@ -65,8 +65,8 @@ private void ParsePriceEntries(dynamic json, string dataType, PriceResponse pric foreach (var entry in entries) { - DateTime start = entry.deliveryStart != null ? DateTime.Parse((string)entry.deliveryStart) : DateTime.MinValue; - DateTime end = entry.deliveryEnd != null ? DateTime.Parse((string)entry.deliveryEnd) : DateTime.MinValue; + DateTime start = entry.deliveryStart != null ? ParseDeliveryDate((string)entry.deliveryStart) : DateTime.MinValue; + DateTime end = entry.deliveryEnd != null ? ParseDeliveryDate((string)entry.deliveryEnd) : DateTime.MinValue; if (priceResponse.Start == DateTime.MinValue || start < priceResponse.Start) { @@ -115,7 +115,28 @@ private void ParsePriceEntries(dynamic json, string dataType, PriceResponse pric } } - private (string, string) GetUrlParams(string dataType, DateTime? endDate, List areas, string currency) + /// + /// Parses a NordPool delivery date string to UTC. + /// HOURLY entries use full ISO timestamps (e.g. "2026-04-18T22:00:00Z") — parsed as exact UTC. + /// DAILY/MONTHLY entries use date-only strings (e.g. "2026-04-11") — treated as UTC midnight of + /// that calendar date, making storage environment-independent (Docker UTC vs Windows UTC+2). + /// + private static DateTime ParseDeliveryDate(string raw) + { + if (raw.Contains('T')) + { + // Full datetime string — parse with timezone awareness and convert to UTC + return DateTimeOffset.Parse(raw, CultureInfo.InvariantCulture).UtcDateTime; + } + + // Date-only string — always store as UTC midnight of that date + return DateTime.SpecifyKind( + DateTime.ParseExact(raw, "yyyy-MM-dd", CultureInfo.InvariantCulture), + DateTimeKind.Utc); + } + + + private static (string, string) GetUrlParams(string dataType, DateTime? endDate, List areas, string currency) { endDate ??= DateTime.UtcNow.AddDays(1); areas ??= new List { "DK1", "DK2", "FI", "NO1", "NO2", "NO3", "NO4", "SE1", "SE2", "SE3", "SE4", "EE", "LT", "LV", "AT", "BE", "FR", "GER", "NL", "PL", "SYS" };