diff --git a/CHANGELOG.md b/CHANGELOG.md index 1f0c1b5..47bcf3e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,13 @@ # Changelog +## [2.0.4](https://github.com/sondresjolyst/garge-api/compare/v2.0.3...v2.0.4) (2026-04-23) + + +### Bug Fixes + +* **electricity:** surface NordPool warnings and refresh today+tomorrow on daily run ([8fd04f1](https://github.com/sondresjolyst/garge-api/commit/8fd04f1ac351f89972267e93017a86c378bd1435)) +* **electricity:** surface NordPool warnings and refresh today+tomorrow on daily run ([#114](https://github.com/sondresjolyst/garge-api/issues/114)) ([2420724](https://github.com/sondresjolyst/garge-api/commit/24207249c7ae7aa0813043673e5f3685eb9da805)) + ## [2.0.3](https://github.com/sondresjolyst/garge-api/compare/v2.0.2...v2.0.3) (2026-04-21) diff --git a/Controllers/SensorController.cs b/Controllers/SensorController.cs index 37a778a..d311215 100644 --- a/Controllers/SensorController.cs +++ b/Controllers/SensorController.cs @@ -1066,5 +1066,79 @@ public async Task GetLatestSensorData(int sensorId) var dto = _mapper.Map(latestData); return Ok(dto); } + + [HttpPost("{sensorId}/photo")] + [SwaggerOperation(Summary = "Upload or replace a photo for a sensor.")] + [SwaggerResponse(200, "Photo saved.")] + [SwaggerResponse(400, "Invalid request.")] + [SwaggerResponse(403, "No access to sensor.")] + public async Task UploadSensorPhoto(int sensorId, [FromBody] UploadSensorPhotoDto dto) + { + if (!await UserCanAccessSensorAsync(sensorId)) + return Forbid(); + + if (string.IsNullOrWhiteSpace(dto.Data) || string.IsNullOrWhiteSpace(dto.ContentType)) + return BadRequest(new { message = "Data and ContentType are required." }); + + var userId = User.FindFirstValue(ClaimTypes.NameIdentifier)!; + + var existing = await _context.SensorPhotos.FirstOrDefaultAsync(sp => sp.SensorId == sensorId); + if (existing != null) + { + existing.Data = dto.Data; + existing.ContentType = dto.ContentType; + existing.UserId = userId; + existing.CreatedAt = DateTime.UtcNow; + } + else + { + _context.SensorPhotos.Add(new Models.Sensor.SensorPhoto + { + SensorId = sensorId, + UserId = userId, + Data = dto.Data, + ContentType = dto.ContentType + }); + } + + await _context.SaveChangesAsync(); + return Ok(new { message = "Photo saved." }); + } + + [HttpGet("{sensorId}/photo")] + [SwaggerOperation(Summary = "Get the photo for a sensor.")] + [SwaggerResponse(200, "Photo data.")] + [SwaggerResponse(403, "No access to sensor.")] + [SwaggerResponse(404, "No photo found.")] + public async Task GetSensorPhoto(int sensorId) + { + if (!await UserCanAccessSensorAsync(sensorId)) + return Forbid(); + + var photo = await _context.SensorPhotos.FirstOrDefaultAsync(sp => sp.SensorId == sensorId); + if (photo == null) + return NotFound(new { message = "No photo found." }); + + return Ok(new { data = photo.Data, contentType = photo.ContentType }); + } + + [HttpDelete("{sensorId}/photo")] + [SwaggerOperation(Summary = "Delete the photo for a sensor.")] + [SwaggerResponse(200, "Photo deleted.")] + [SwaggerResponse(403, "No access to sensor.")] + [SwaggerResponse(404, "No photo found.")] + public async Task DeleteSensorPhoto(int sensorId) + { + if (!await UserCanAccessSensorAsync(sensorId)) + return Forbid(); + + var photo = await _context.SensorPhotos.FirstOrDefaultAsync(sp => sp.SensorId == sensorId); + if (photo == null) + return NotFound(new { message = "No photo found." }); + + _context.SensorPhotos.Remove(photo); + await _context.SaveChangesAsync(); + return Ok(new { message = "Photo deleted." }); + } } } diff --git a/Dtos/Sensor/UploadSensorPhotoDto.cs b/Dtos/Sensor/UploadSensorPhotoDto.cs new file mode 100644 index 0000000..2da0c70 --- /dev/null +++ b/Dtos/Sensor/UploadSensorPhotoDto.cs @@ -0,0 +1,14 @@ +using System.ComponentModel.DataAnnotations; + +namespace garge_api.Dtos.Sensor +{ + public class UploadSensorPhotoDto + { + [Required] + public required string Data { get; set; } + + [Required] + [MaxLength(50)] + public required string ContentType { get; set; } + } +} diff --git a/Migrations/20260427164549_AddSensorPhoto.Designer.cs b/Migrations/20260427164549_AddSensorPhoto.Designer.cs new file mode 100644 index 0000000..1acd70c --- /dev/null +++ b/Migrations/20260427164549_AddSensorPhoto.Designer.cs @@ -0,0 +1,1153 @@ +// +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("20260427164549_AddSensorPhoto")] + partial class AddSensorPhoto + { + /// + 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.SensorPhoto", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ContentType") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Data") + .IsRequired() + .HasColumnType("text"); + + b.Property("SensorId") + .HasColumnType("integer"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("SensorId") + .IsUnique(); + + b.HasIndex("UserId"); + + b.ToTable("SensorPhotos"); + }); + + modelBuilder.Entity("garge_api.Models.Sensor.UserSensor", b => + { + b.Property("UserId") + .HasColumnType("text"); + + b.Property("SensorId") + .HasColumnType("integer"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.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.SensorPhoto", b => + { + b.HasOne("garge_api.Models.Sensor.Sensor", "Sensor") + .WithMany() + .HasForeignKey("SensorId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Sensor"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("garge_api.Models.Sensor.UserSensor", b => + { + b.HasOne("garge_api.Models.Sensor.Sensor", "Sensor") + .WithMany() + .HasForeignKey("SensorId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Sensor"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("garge_api.Models.Sensor.UserSensorCustomName", b => + { + b.HasOne("garge_api.Models.Sensor.Sensor", "Sensor") + .WithMany() + .HasForeignKey("SensorId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Sensor"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("garge_api.Models.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/20260427164549_AddSensorPhoto.cs b/Migrations/20260427164549_AddSensorPhoto.cs new file mode 100644 index 0000000..80b5855 --- /dev/null +++ b/Migrations/20260427164549_AddSensorPhoto.cs @@ -0,0 +1,63 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace garge_api.Migrations +{ + /// + public partial class AddSensorPhoto : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "SensorPhotos", + columns: table => new + { + Id = table.Column(type: "integer", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + SensorId = table.Column(type: "integer", nullable: false), + UserId = table.Column(type: "text", nullable: false), + Data = table.Column(type: "text", nullable: false), + ContentType = table.Column(type: "character varying(50)", maxLength: 50, nullable: false), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_SensorPhotos", x => x.Id); + table.ForeignKey( + name: "FK_SensorPhotos_AspNetUsers_UserId", + column: x => x.UserId, + principalTable: "AspNetUsers", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "FK_SensorPhotos_Sensors_SensorId", + column: x => x.SensorId, + principalTable: "Sensors", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_SensorPhotos_SensorId", + table: "SensorPhotos", + column: "SensorId", + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_SensorPhotos_UserId", + table: "SensorPhotos", + column: "UserId"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "SensorPhotos"); + } + } +} diff --git a/Migrations/ApplicationDbContextModelSnapshot.cs b/Migrations/ApplicationDbContextModelSnapshot.cs index 77e3c0e..11a82d9 100644 --- a/Migrations/ApplicationDbContextModelSnapshot.cs +++ b/Migrations/ApplicationDbContextModelSnapshot.cs @@ -704,6 +704,43 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.ToTable("SensorData"); }); + modelBuilder.Entity("garge_api.Models.Sensor.SensorPhoto", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ContentType") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Data") + .IsRequired() + .HasColumnType("text"); + + b.Property("SensorId") + .HasColumnType("integer"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("SensorId") + .IsUnique(); + + b.HasIndex("UserId"); + + b.ToTable("SensorPhotos"); + }); + modelBuilder.Entity("garge_api.Models.Sensor.UserSensor", b => { b.Property("UserId") @@ -995,6 +1032,25 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Navigation("Sensor"); }); + modelBuilder.Entity("garge_api.Models.Sensor.SensorPhoto", b => + { + b.HasOne("garge_api.Models.Sensor.Sensor", "Sensor") + .WithMany() + .HasForeignKey("SensorId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Sensor"); + + b.Navigation("User"); + }); + modelBuilder.Entity("garge_api.Models.Sensor.UserSensor", b => { b.HasOne("garge_api.Models.Sensor.Sensor", "Sensor") diff --git a/Models/ApplicationDbContext.cs b/Models/ApplicationDbContext.cs index fcd9079..c404300 100644 --- a/Models/ApplicationDbContext.cs +++ b/Models/ApplicationDbContext.cs @@ -36,6 +36,7 @@ public partial class ApplicationDbContext(DbContextOptions public DbSet UserSensors { get; set; } public DbSet UserSwitches { get; set; } public DbSet StoredElectricityPrices { get; set; } + public DbSet SensorPhotos { get; set; } protected override void OnModelCreating(ModelBuilder modelBuilder) { @@ -173,6 +174,22 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) modelBuilder.Entity() .HasIndex(p => new { p.Area, p.Resolution, p.DeliveryStart }) .IsUnique(); + + modelBuilder.Entity() + .HasOne(sp => sp.Sensor) + .WithMany() + .HasForeignKey(sp => sp.SensorId) + .OnDelete(DeleteBehavior.Cascade); + + modelBuilder.Entity() + .HasOne(sp => sp.User) + .WithMany() + .HasForeignKey(sp => sp.UserId) + .OnDelete(DeleteBehavior.Cascade); + + modelBuilder.Entity() + .HasIndex(sp => sp.SensorId) + .IsUnique(); } public void EnsureTriggers() { diff --git a/Models/Sensor/SensorPhoto.cs b/Models/Sensor/SensorPhoto.cs new file mode 100644 index 0000000..61d9f5c --- /dev/null +++ b/Models/Sensor/SensorPhoto.cs @@ -0,0 +1,36 @@ +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; +using garge_api.Models.Admin; +using Swashbuckle.AspNetCore.Annotations; + +namespace garge_api.Models.Sensor +{ + public class SensorPhoto + { + [Key] + [DatabaseGenerated(DatabaseGeneratedOption.Identity)] + [SwaggerSchema(ReadOnly = true)] + public int Id { get; set; } + + [Required] + public int SensorId { get; set; } + + [Required] + public required string UserId { get; set; } + + [Required] + public required string Data { get; set; } + + [Required] + [MaxLength(50)] + public required string ContentType { get; set; } + + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; + + [ForeignKey(nameof(SensorId))] + public Sensor? Sensor { get; set; } + + [ForeignKey(nameof(UserId))] + public User? User { get; set; } + } +}