diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index e3ff242..beaedef 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -19,7 +19,7 @@ permissions: jobs: dev: if: github.ref == 'refs/heads/development' - uses: equinor/ops-actions/.github/workflows/docker.yml@6f3c4901194b286f11800f60fc034417e2c8480a # v9.36.0 + uses: equinor/ops-actions/.github/workflows/docker.yml@39749d0c32762076499120f881e963687374420c # v9.37.1 secrets: DOCKER_PASSWORD: ${{ secrets.DOCKER_PASSWORD }} with: @@ -31,7 +31,7 @@ jobs: prod: if: github.event_name == 'release' || github.event_name == 'workflow_dispatch' - uses: equinor/ops-actions/.github/workflows/docker.yml@6f3c4901194b286f11800f60fc034417e2c8480a # v9.36.0 + uses: equinor/ops-actions/.github/workflows/docker.yml@39749d0c32762076499120f881e963687374420c # v9.37.1 secrets: DOCKER_PASSWORD: ${{ secrets.DOCKER_PASSWORD }} with: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index c12a427..de21dfa 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -13,6 +13,6 @@ permissions: jobs: release-please: name: Release Please - uses: equinor/ops-actions/.github/workflows/release-please.yml@6f3c4901194b286f11800f60fc034417e2c8480a # v9.36.0 + uses: equinor/ops-actions/.github/workflows/release-please.yml@39749d0c32762076499120f881e963687374420c # v9.37.1 with: release_type: simple 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/AdminController.cs b/Controllers/AdminController.cs index bd0e2a8..52377a4 100644 --- a/Controllers/AdminController.cs +++ b/Controllers/AdminController.cs @@ -6,6 +6,7 @@ using Swashbuckle.AspNetCore.Annotations; using AutoMapper; using garge_api.Models.Admin; +using Microsoft.EntityFrameworkCore; namespace garge_api.Controllers { @@ -200,21 +201,119 @@ public async Task RemoveRole([FromRoute] string roleName, [FromQu } /// - /// Gets all users. + /// Gets all users with their roles. /// /// A list of all users. [HttpGet("/api/users")] [SwaggerOperation(Summary = "Gets all users.")] [SwaggerResponse(200, "Users retrieved successfully.", typeof(IEnumerable))] - public IActionResult GetUsers() + public async Task GetUsers() { _logger.LogInformation("GetUsers called by {@LogData}", new { User = User.Identity?.Name }); var users = _userManager.Users.ToList(); - var dtos = _mapper.Map>(users); + var dtos = new List(); + foreach (var user in users) + { + var dto = _mapper.Map(user); + dto.Roles = await _userManager.GetRolesAsync(user); + dtos.Add(dto); + } return Ok(dtos); } + /// + /// Gets aggregate platform stats. + /// + [HttpGet("/api/admin/stats")] + [SwaggerOperation(Summary = "Gets aggregate platform stats.")] + [SwaggerResponse(200, "Stats retrieved successfully.", typeof(AdminStatsDto))] + public async Task GetStats() + { + _logger.LogInformation("GetStats called by {@LogData}", new { User = User.Identity?.Name }); + + var stats = new AdminStatsDto + { + TotalUsers = await _userManager.Users.CountAsync(), + TotalSensors = await _context.Sensors.CountAsync(), + TotalSwitches = await _context.Switches.CountAsync(), + ActiveAutomations = await _context.AutomationRules.CountAsync(r => r.IsEnabled), + }; + return Ok(stats); + } + + /// + /// Gets all discovered MQTT devices. + /// + [HttpGet("/api/admin/devices")] + [SwaggerOperation(Summary = "Gets all discovered MQTT devices.")] + public async Task GetDevices() + { + _logger.LogInformation("GetDevices called by {@LogData}", new { User = User.Identity?.Name }); + + var devices = await _context.DiscoveredDevices + .OrderByDescending(d => d.Timestamp) + .ToListAsync(); + return Ok(devices); + } + + /// + /// Gets cumulative daily stats over time for charting. + /// + [HttpGet("/api/admin/stats/history")] + [SwaggerOperation(Summary = "Gets cumulative daily stats over time.")] + public async Task GetStatsHistory() + { + _logger.LogInformation("GetStatsHistory called by {@LogData}", new { User = User.Identity?.Name }); + + var userDates = await _userManager.Users + .Select(u => u.CreatedAt.Date) + .ToListAsync(); + + var sensorDates = await _context.Sensors + .Select(s => s.CreatedAt.Date) + .ToListAsync(); + + var switchDates = await _context.Switches + .Select(s => s.CreatedAt.Date) + .ToListAsync(); + + var automationDates = await _context.AutomationRules + .Select(a => a.CreatedAt.Date) + .ToListAsync(); + + var sanityFloor = new DateTime(2020, 1, 1, 0, 0, 0, DateTimeKind.Utc); + var allDates = userDates.Concat(sensorDates).Concat(switchDates).Concat(automationDates) + .Where(d => d >= sanityFloor) + .ToList(); + if (!allDates.Any()) return Ok(new List()); + + var start = allDates.Min(); + var today = DateTime.UtcNow.Date; + + var result = new List(); + int users = 0, sensors = 0, switches = 0, automations = 0; + + for (var date = start; date <= today; date = date.AddDays(1)) + { + users += userDates.Count(d => d == date); + sensors += sensorDates.Count(d => d == date); + switches += switchDates.Count(d => d == date); + automations += automationDates.Count(d => d == date); + + result.Add(new + { + date = date.ToString("yyyy-MM-dd"), + totalUsers = users, + totalSensors = sensors, + totalSwitches = switches, + totalAutomations = automations, + }); + } + + return Ok(result); + } + /// /// Deletes a user by their ID. /// diff --git a/Controllers/SensorActivitiesController.cs b/Controllers/SensorActivitiesController.cs new file mode 100644 index 0000000..e403ffc --- /dev/null +++ b/Controllers/SensorActivitiesController.cs @@ -0,0 +1,227 @@ +using AutoMapper; +using garge_api.Dtos.Sensor; +using garge_api.Models; +using garge_api.Models.Sensor; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Cors; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using Swashbuckle.AspNetCore.Annotations; +using System.Security.Claims; + +namespace garge_api.Controllers +{ + [ApiController] + [Route("api/sensors/{sensorId}/activities")] + [EnableCors("AllowAllOrigins")] + [Authorize] + public class SensorActivitiesController : ControllerBase + { + private readonly ApplicationDbContext _context; + private readonly IMapper _mapper; + private readonly ILogger _logger; + private static readonly List AdminRoles = new() { "SensorAdmin", "admin" }; + + public SensorActivitiesController( + ApplicationDbContext context, + IMapper mapper, + ILogger logger) + { + _context = context; + _mapper = mapper; + _logger = logger; + } + + private bool IsAdmin() + { + var userRoles = User.FindAll(ClaimTypes.Role).Select(r => r.Value).ToList(); + return userRoles.Any(role => AdminRoles.Contains(role, StringComparer.OrdinalIgnoreCase)); + } + + private async Task UserCanAccessSensorAsync(int sensorId) + { + if (IsAdmin()) return true; + var userId = User.FindFirstValue(ClaimTypes.NameIdentifier); + return await _context.UserSensors.AnyAsync(us => us.UserId == userId && us.SensorId == sensorId); + } + + /// + /// Lists activities logged for a sensor (e.g. motorcycle voltmeter), most recent first. + /// + [HttpGet] + [SwaggerOperation(Summary = "Lists activities for a sensor.")] + [SwaggerResponse(200, "The list of activities.", typeof(IEnumerable))] + [SwaggerResponse(404, "Sensor not found.")] + [SwaggerResponse(403, "User does not have access to this sensor.")] + public async Task GetActivities(int sensorId) + { + _logger.LogInformation("GetActivities called by {@LogData}", new { User = User.Identity?.Name, sensorId }); + + var sensorExists = await _context.Sensors.AnyAsync(s => s.Id == sensorId); + if (!sensorExists) + { + _logger.LogWarning("GetActivities not found: {@LogData}", new { sensorId }); + return NotFound(new { message = "Sensor not found!" }); + } + + if (!await UserCanAccessSensorAsync(sensorId)) + { + _logger.LogWarning("GetActivities forbidden for {@LogData}", new { User = User.Identity?.Name, sensorId }); + return Forbid(); + } + + var activities = await _context.SensorActivities + .Where(a => a.SensorId == sensorId) + .OrderByDescending(a => a.ActivityDate) + .ThenByDescending(a => a.CreatedAt) + .ToListAsync(); + + var dtos = _mapper.Map>(activities); + return Ok(dtos); + } + + /// + /// Retrieves a single activity by id. + /// + [HttpGet("{activityId}")] + [SwaggerOperation(Summary = "Retrieves a single activity by id.")] + [SwaggerResponse(200, "The activity.", typeof(SensorActivityDto))] + [SwaggerResponse(404, "Sensor or activity not found.")] + [SwaggerResponse(403, "User does not have access to this sensor.")] + public async Task GetActivity(int sensorId, int activityId) + { + var sensorExists = await _context.Sensors.AnyAsync(s => s.Id == sensorId); + if (!sensorExists) + return NotFound(new { message = "Sensor not found!" }); + + if (!await UserCanAccessSensorAsync(sensorId)) + return Forbid(); + + var activity = await _context.SensorActivities + .FirstOrDefaultAsync(a => a.Id == activityId && a.SensorId == sensorId); + + if (activity == null) + return NotFound(new { message = "Activity not found!" }); + + return Ok(_mapper.Map(activity)); + } + + /// + /// Creates a new activity entry for a sensor. + /// + [HttpPost] + [SwaggerOperation(Summary = "Creates a new activity for a sensor.")] + [SwaggerResponse(201, "The created activity.", typeof(SensorActivityDto))] + [SwaggerResponse(400, "Bad request.")] + [SwaggerResponse(404, "Sensor not found.")] + [SwaggerResponse(403, "User does not have access to this sensor.")] + public async Task CreateActivity(int sensorId, [FromBody] CreateSensorActivityDto dto) + { + _logger.LogInformation("CreateActivity called by {@LogData}", new { User = User.Identity?.Name, sensorId }); + + var sensorExists = await _context.Sensors.AnyAsync(s => s.Id == sensorId); + if (!sensorExists) + return NotFound(new { message = "Sensor not found!" }); + + if (!await UserCanAccessSensorAsync(sensorId)) + return Forbid(); + + var userId = User.FindFirstValue(ClaimTypes.NameIdentifier); + if (string.IsNullOrEmpty(userId)) + return Unauthorized(); + + if (string.IsNullOrWhiteSpace(dto.Title)) + return BadRequest(new { message = "Title is required." }); + + var now = DateTime.UtcNow; + var activity = new SensorActivity + { + SensorId = sensorId, + UserId = userId, + Title = dto.Title, + Notes = dto.Notes, + ActivityDate = (dto.ActivityDate ?? now).ToUniversalTime(), + CreatedAt = now + }; + + _context.SensorActivities.Add(activity); + await _context.SaveChangesAsync(); + + var resultDto = _mapper.Map(activity); + _logger.LogInformation("Activity created: {@LogData}", new { activity.Id, sensorId }); + return CreatedAtAction(nameof(GetActivity), new { sensorId, activityId = activity.Id }, resultDto); + } + + /// + /// Updates an existing activity. Only the user who created it (or an admin) may update. + /// + [HttpPut("{activityId}")] + [SwaggerOperation(Summary = "Updates an activity.")] + [SwaggerResponse(200, "The updated activity.", typeof(SensorActivityDto))] + [SwaggerResponse(404, "Sensor or activity not found.")] + [SwaggerResponse(403, "User does not have access to this sensor or did not create the activity.")] + public async Task UpdateActivity(int sensorId, int activityId, [FromBody] UpdateSensorActivityDto dto) + { + var sensorExists = await _context.Sensors.AnyAsync(s => s.Id == sensorId); + if (!sensorExists) + return NotFound(new { message = "Sensor not found!" }); + + if (!await UserCanAccessSensorAsync(sensorId)) + return Forbid(); + + var activity = await _context.SensorActivities + .FirstOrDefaultAsync(a => a.Id == activityId && a.SensorId == sensorId); + if (activity == null) + return NotFound(new { message = "Activity not found!" }); + + var currentUserId = User.FindFirstValue(ClaimTypes.NameIdentifier); + if (!IsAdmin() && activity.UserId != currentUserId) + return Forbid(); + + if (string.IsNullOrWhiteSpace(dto.Title)) + return BadRequest(new { message = "Title is required." }); + + activity.Title = dto.Title; + activity.Notes = dto.Notes; + if (dto.ActivityDate.HasValue) + activity.ActivityDate = dto.ActivityDate.Value.ToUniversalTime(); + activity.UpdatedAt = DateTime.UtcNow; + + await _context.SaveChangesAsync(); + + return Ok(_mapper.Map(activity)); + } + + /// + /// Deletes an activity. Only the user who created it (or an admin) may delete. + /// + [HttpDelete("{activityId}")] + [SwaggerOperation(Summary = "Deletes an activity.")] + [SwaggerResponse(204, "Activity deleted.")] + [SwaggerResponse(404, "Sensor or activity not found.")] + [SwaggerResponse(403, "User does not have access to this sensor or did not create the activity.")] + public async Task DeleteActivity(int sensorId, int activityId) + { + var sensorExists = await _context.Sensors.AnyAsync(s => s.Id == sensorId); + if (!sensorExists) + return NotFound(new { message = "Sensor not found!" }); + + if (!await UserCanAccessSensorAsync(sensorId)) + return Forbid(); + + var activity = await _context.SensorActivities + .FirstOrDefaultAsync(a => a.Id == activityId && a.SensorId == sensorId); + if (activity == null) + return NotFound(new { message = "Activity not found!" }); + + var currentUserId = User.FindFirstValue(ClaimTypes.NameIdentifier); + if (!IsAdmin() && activity.UserId != currentUserId) + return Forbid(); + + _context.SensorActivities.Remove(activity); + await _context.SaveChangesAsync(); + + return NoContent(); + } + } +} 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/Admin/AdminStatsDto.cs b/Dtos/Admin/AdminStatsDto.cs new file mode 100644 index 0000000..d926e0f --- /dev/null +++ b/Dtos/Admin/AdminStatsDto.cs @@ -0,0 +1,10 @@ +namespace garge_api.Dtos.Admin +{ + public class AdminStatsDto + { + public int TotalUsers { get; set; } + public int TotalSensors { get; set; } + public int TotalSwitches { get; set; } + public int ActiveAutomations { get; set; } + } +} diff --git a/Dtos/Admin/UserDto.cs b/Dtos/Admin/UserDto.cs index 9a63cbd..72606b9 100644 --- a/Dtos/Admin/UserDto.cs +++ b/Dtos/Admin/UserDto.cs @@ -7,5 +7,6 @@ public class UserDto public required string FirstName { get; set; } public required string LastName { get; set; } public required string Email { get; set; } + public IList Roles { get; set; } = []; } } diff --git a/Dtos/Sensor/CreateSensorActivityDto.cs b/Dtos/Sensor/CreateSensorActivityDto.cs new file mode 100644 index 0000000..952da73 --- /dev/null +++ b/Dtos/Sensor/CreateSensorActivityDto.cs @@ -0,0 +1,18 @@ +using System.ComponentModel.DataAnnotations; + +namespace garge_api.Dtos.Sensor +{ + public class CreateSensorActivityDto + { + [Required] + [MaxLength(100)] + public required string Title { get; set; } + + public string? Notes { get; set; } + + /// + /// When the activity occurred. Defaults to the current UTC time if not supplied. + /// + public DateTime? ActivityDate { get; set; } + } +} diff --git a/Dtos/Sensor/SensorActivityDto.cs b/Dtos/Sensor/SensorActivityDto.cs new file mode 100644 index 0000000..df1d687 --- /dev/null +++ b/Dtos/Sensor/SensorActivityDto.cs @@ -0,0 +1,14 @@ +namespace garge_api.Dtos.Sensor +{ + public class SensorActivityDto + { + public int Id { get; set; } + public int SensorId { get; set; } + public string UserId { get; set; } = default!; + public required string Title { get; set; } + public string? Notes { get; set; } + public DateTime ActivityDate { get; set; } + public DateTime CreatedAt { get; set; } + public DateTime? UpdatedAt { get; set; } + } +} diff --git a/Dtos/Sensor/UpdateSensorActivityDto.cs b/Dtos/Sensor/UpdateSensorActivityDto.cs new file mode 100644 index 0000000..fb8d0ad --- /dev/null +++ b/Dtos/Sensor/UpdateSensorActivityDto.cs @@ -0,0 +1,15 @@ +using System.ComponentModel.DataAnnotations; + +namespace garge_api.Dtos.Sensor +{ + public class UpdateSensorActivityDto + { + [Required] + [MaxLength(100)] + public required string Title { get; set; } + + public string? Notes { get; set; } + + public DateTime? ActivityDate { get; set; } + } +} 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/20260426194359_AddSensorActivity.Designer.cs b/Migrations/20260426194359_AddSensorActivity.Designer.cs new file mode 100644 index 0000000..cfc4883 --- /dev/null +++ b/Migrations/20260426194359_AddSensorActivity.Designer.cs @@ -0,0 +1,1149 @@ +// +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("20260426194359_AddSensorActivity")] + partial class AddSensorActivity + { + /// + 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.SensorActivity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ActivityDate") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Notes") + .HasColumnType("text"); + + b.Property("SensorId") + .HasColumnType("integer"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("ActivityDate"); + + b.HasIndex("SensorId"); + + b.ToTable("SensorActivities"); + }); + + modelBuilder.Entity("garge_api.Models.Sensor.SensorData", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("SensorId") + .HasColumnType("integer"); + + b.Property("Timestamp") + .HasColumnType("timestamp with time zone"); + + b.Property("Value") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("SensorId"); + + b.HasIndex("Timestamp"); + + b.HasIndex("SensorId", "Timestamp") + .HasDatabaseName("IX_SensorData_SensorId_Timestamp"); + + b.ToTable("SensorData"); + }); + + modelBuilder.Entity("garge_api.Models.Sensor.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.SensorActivity", b => + { + b.HasOne("garge_api.Models.Sensor.Sensor", "Sensor") + .WithMany() + .HasForeignKey("SensorId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Sensor"); + }); + + modelBuilder.Entity("garge_api.Models.Sensor.SensorData", b => + { + b.HasOne("garge_api.Models.Sensor.Sensor", "Sensor") + .WithMany() + .HasForeignKey("SensorId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Sensor"); + }); + + modelBuilder.Entity("garge_api.Models.Sensor.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/20260426194359_AddSensorActivity.cs b/Migrations/20260426194359_AddSensorActivity.cs new file mode 100644 index 0000000..80d5227 --- /dev/null +++ b/Migrations/20260426194359_AddSensorActivity.cs @@ -0,0 +1,58 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace garge_api.Migrations +{ + /// + public partial class AddSensorActivity : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "SensorActivities", + 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), + Title = table.Column(type: "character varying(100)", maxLength: 100, nullable: false), + Notes = table.Column(type: "text", nullable: true), + ActivityDate = table.Column(type: "timestamp with time zone", nullable: false), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false), + UpdatedAt = table.Column(type: "timestamp with time zone", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_SensorActivities", x => x.Id); + table.ForeignKey( + name: "FK_SensorActivities_Sensors_SensorId", + column: x => x.SensorId, + principalTable: "Sensors", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_SensorActivities_ActivityDate", + table: "SensorActivities", + column: "ActivityDate"); + + migrationBuilder.CreateIndex( + name: "IX_SensorActivities_SensorId", + table: "SensorActivities", + column: "SensorId"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "SensorActivities"); + } + } +} diff --git a/Migrations/20260427143229_AddOdometerToSensorActivity.Designer.cs b/Migrations/20260427143229_AddOdometerToSensorActivity.Designer.cs new file mode 100644 index 0000000..d0db901 --- /dev/null +++ b/Migrations/20260427143229_AddOdometerToSensorActivity.Designer.cs @@ -0,0 +1,1152 @@ +// +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("20260427143229_AddOdometerToSensorActivity")] + partial class AddOdometerToSensorActivity + { + /// + 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.SensorActivity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ActivityDate") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Notes") + .HasColumnType("text"); + + b.Property("OdometerKm") + .HasColumnType("integer"); + + b.Property("SensorId") + .HasColumnType("integer"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("ActivityDate"); + + b.HasIndex("SensorId"); + + b.ToTable("SensorActivities"); + }); + + modelBuilder.Entity("garge_api.Models.Sensor.SensorData", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("SensorId") + .HasColumnType("integer"); + + b.Property("Timestamp") + .HasColumnType("timestamp with time zone"); + + b.Property("Value") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("SensorId"); + + b.HasIndex("Timestamp"); + + b.HasIndex("SensorId", "Timestamp") + .HasDatabaseName("IX_SensorData_SensorId_Timestamp"); + + b.ToTable("SensorData"); + }); + + modelBuilder.Entity("garge_api.Models.Sensor.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.SensorActivity", b => + { + b.HasOne("garge_api.Models.Sensor.Sensor", "Sensor") + .WithMany() + .HasForeignKey("SensorId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Sensor"); + }); + + modelBuilder.Entity("garge_api.Models.Sensor.SensorData", b => + { + b.HasOne("garge_api.Models.Sensor.Sensor", "Sensor") + .WithMany() + .HasForeignKey("SensorId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Sensor"); + }); + + modelBuilder.Entity("garge_api.Models.Sensor.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/20260427143229_AddOdometerToSensorActivity.cs b/Migrations/20260427143229_AddOdometerToSensorActivity.cs new file mode 100644 index 0000000..7bf1e56 --- /dev/null +++ b/Migrations/20260427143229_AddOdometerToSensorActivity.cs @@ -0,0 +1,28 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace garge_api.Migrations +{ + /// + public partial class AddOdometerToSensorActivity : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "OdometerKm", + table: "SensorActivities", + type: "integer", + nullable: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "OdometerKm", + table: "SensorActivities"); + } + } +} diff --git a/Migrations/20260427161221_AddCreatedAt.Designer.cs b/Migrations/20260427161221_AddCreatedAt.Designer.cs new file mode 100644 index 0000000..84a553c --- /dev/null +++ b/Migrations/20260427161221_AddCreatedAt.Designer.cs @@ -0,0 +1,1164 @@ +// +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("20260427161221_AddCreatedAt")] + partial class AddCreatedAt + { + /// + 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("CreatedAt") + .HasColumnType("timestamp with time zone"); + + 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("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ElectricityPriceArea") + .HasColumnType("text"); + + b.Property("ElectricityPriceCondition") + .HasColumnType("text"); + + b.Property("ElectricityPriceOperator") + .HasColumnType("text"); + + b.Property("ElectricityPriceThreshold") + .HasColumnType("double precision"); + + b.Property("IsEnabled") + .HasColumnType("boolean"); + + b.Property("LastTriggeredAt") + .HasColumnType("timestamp with time zone"); + + b.Property("SensorId") + .HasColumnType("integer"); + + b.Property("SensorType") + .IsRequired() + .HasColumnType("text"); + + b.Property("TargetId") + .HasColumnType("integer"); + + b.Property("TargetType") + .IsRequired() + .HasColumnType("text"); + + b.Property("Threshold") + .HasColumnType("double precision"); + + b.Property("TimerActivatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("TimerDurationHours") + .HasColumnType("double precision"); + + b.HasKey("Id"); + + b.HasIndex("TargetType", "TargetId", "SensorType", "SensorId", "Condition", "Threshold", "Action") + .IsUnique(); + + b.ToTable("AutomationRules"); + }); + + modelBuilder.Entity("garge_api.Models.Electricity.StoredElectricityPrice", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Area") + .IsRequired() + .HasColumnType("text"); + + b.Property("Currency") + .IsRequired() + .HasColumnType("text"); + + b.Property("DeliveryEnd") + .HasColumnType("timestamp with time zone"); + + b.Property("DeliveryStart") + .HasColumnType("timestamp with time zone"); + + b.Property("FetchedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Resolution") + .IsRequired() + .HasColumnType("text"); + + b.Property("Value") + .HasColumnType("double precision"); + + b.HasKey("Id"); + + b.HasIndex("Area", "Resolution", "DeliveryStart") + .IsUnique(); + + b.ToTable("StoredElectricityPrices"); + }); + + modelBuilder.Entity("garge_api.Models.Group.Group", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Icon") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.ToTable("Groups"); + }); + + modelBuilder.Entity("garge_api.Models.Group.GroupSensor", b => + { + b.Property("GroupId") + .HasColumnType("integer"); + + b.Property("SensorId") + .HasColumnType("integer"); + + b.HasKey("GroupId", "SensorId"); + + b.HasIndex("SensorId"); + + b.ToTable("GroupSensors"); + }); + + modelBuilder.Entity("garge_api.Models.Group.GroupSwitch", b => + { + b.Property("GroupId") + .HasColumnType("integer"); + + b.Property("SwitchId") + .HasColumnType("integer"); + + b.HasKey("GroupId", "SwitchId"); + + b.HasIndex("SwitchId"); + + b.ToTable("GroupSwitches"); + }); + + modelBuilder.Entity("garge_api.Models.Mqtt.DiscoveredDevice", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("DiscoveredBy") + .IsRequired() + .HasColumnType("text"); + + b.Property("Target") + .IsRequired() + .HasColumnType("text"); + + b.Property("Timestamp") + .HasColumnType("timestamp with time zone"); + + b.Property("Type") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("DiscoveredBy", "Target", "Type") + .IsUnique(); + + b.ToTable("DiscoveredDevices"); + }); + + modelBuilder.Entity("garge_api.Models.Mqtt.EMQXMqttAcl", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Action") + .IsRequired() + .HasColumnType("text"); + + b.Property("Permission") + .IsRequired() + .HasColumnType("text"); + + b.Property("Qos") + .HasColumnType("smallint"); + + b.Property("Retain") + .HasColumnType("smallint"); + + b.Property("Topic") + .IsRequired() + .HasColumnType("text"); + + b.Property("Username") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("Username", "Permission", "Action", "Topic", "Qos", "Retain") + .IsUnique(); + + b.ToTable("EMQXMqttAcls"); + }); + + modelBuilder.Entity("garge_api.Models.Mqtt.EMQXMqttUser", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("IsSuperuser") + .HasColumnType("boolean"); + + b.Property("PasswordHash") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Salt") + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.Property("Username") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("Username") + .IsUnique(); + + b.ToTable("EMQXMqttUsers"); + }); + + modelBuilder.Entity("garge_api.Models.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("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DefaultName") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("ParentName") + .IsRequired() + .HasColumnType("text"); + + b.Property("RegistrationCode") + .IsRequired() + .HasColumnType("text"); + + b.Property("Role") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Type") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("Sensors"); + }); + + modelBuilder.Entity("garge_api.Models.Sensor.SensorActivity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ActivityDate") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Notes") + .HasColumnType("text"); + + b.Property("OdometerKm") + .HasColumnType("integer"); + + b.Property("SensorId") + .HasColumnType("integer"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("ActivityDate"); + + b.HasIndex("SensorId"); + + b.ToTable("SensorActivities"); + }); + + modelBuilder.Entity("garge_api.Models.Sensor.SensorData", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("SensorId") + .HasColumnType("integer"); + + b.Property("Timestamp") + .HasColumnType("timestamp with time zone"); + + b.Property("Value") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("SensorId"); + + b.HasIndex("Timestamp"); + + b.HasIndex("SensorId", "Timestamp") + .HasDatabaseName("IX_SensorData_SensorId_Timestamp"); + + b.ToTable("SensorData"); + }); + + modelBuilder.Entity("garge_api.Models.Sensor.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("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("RegistrationCode") + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("Role") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Type") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.HasKey("Id"); + + b.ToTable("Switches"); + }); + + modelBuilder.Entity("garge_api.Models.Switch.SwitchData", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("SwitchId") + .HasColumnType("integer"); + + b.Property("Timestamp") + .HasColumnType("timestamp with time zone"); + + b.Property("Value") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("SwitchId"); + + b.ToTable("SwitchData", (string)null); + }); + + modelBuilder.Entity("garge_api.Models.Switch.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.SensorActivity", b => + { + b.HasOne("garge_api.Models.Sensor.Sensor", "Sensor") + .WithMany() + .HasForeignKey("SensorId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Sensor"); + }); + + modelBuilder.Entity("garge_api.Models.Sensor.SensorData", b => + { + b.HasOne("garge_api.Models.Sensor.Sensor", "Sensor") + .WithMany() + .HasForeignKey("SensorId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Sensor"); + }); + + modelBuilder.Entity("garge_api.Models.Sensor.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/20260427161221_AddCreatedAt.cs b/Migrations/20260427161221_AddCreatedAt.cs new file mode 100644 index 0000000..81dff2f --- /dev/null +++ b/Migrations/20260427161221_AddCreatedAt.cs @@ -0,0 +1,63 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace garge_api.Migrations +{ + /// + public partial class AddCreatedAt : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "CreatedAt", + table: "Switches", + type: "timestamp with time zone", + nullable: false, + defaultValue: new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified)); + + migrationBuilder.AddColumn( + name: "CreatedAt", + table: "Sensors", + type: "timestamp with time zone", + nullable: false, + defaultValue: new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified)); + + migrationBuilder.AddColumn( + name: "CreatedAt", + table: "AutomationRules", + type: "timestamp with time zone", + nullable: false, + defaultValue: new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified)); + + migrationBuilder.AddColumn( + name: "CreatedAt", + table: "AspNetUsers", + type: "timestamp with time zone", + nullable: false, + defaultValue: new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified)); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "CreatedAt", + table: "Switches"); + + migrationBuilder.DropColumn( + name: "CreatedAt", + table: "Sensors"); + + migrationBuilder.DropColumn( + name: "CreatedAt", + table: "AutomationRules"); + + migrationBuilder.DropColumn( + name: "CreatedAt", + table: "AspNetUsers"); + } + } +} 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..683c4aa 100644 --- a/Migrations/ApplicationDbContextModelSnapshot.cs +++ b/Migrations/ApplicationDbContextModelSnapshot.cs @@ -166,6 +166,9 @@ protected override void BuildModel(ModelBuilder modelBuilder) .IsConcurrencyToken() .HasColumnType("text"); + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + b.Property("Email") .HasMaxLength(256) .HasColumnType("character varying(256)"); @@ -342,6 +345,9 @@ protected override void BuildModel(ModelBuilder modelBuilder) .IsRequired() .HasColumnType("text"); + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + b.Property("ElectricityPriceArea") .HasColumnType("text"); @@ -638,6 +644,9 @@ protected override void BuildModel(ModelBuilder modelBuilder) NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + b.Property("DefaultName") .IsRequired() .HasMaxLength(50) @@ -674,6 +683,50 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.ToTable("Sensors"); }); + modelBuilder.Entity("garge_api.Models.Sensor.SensorActivity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ActivityDate") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Notes") + .HasColumnType("text"); + + b.Property("OdometerKm") + .HasColumnType("integer"); + + b.Property("SensorId") + .HasColumnType("integer"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("ActivityDate"); + + b.HasIndex("SensorId"); + + b.ToTable("SensorActivities"); + }); + modelBuilder.Entity("garge_api.Models.Sensor.SensorData", b => { b.Property("Id") @@ -704,6 +757,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") @@ -752,6 +842,9 @@ protected override void BuildModel(ModelBuilder modelBuilder) NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + b.Property("Name") .IsRequired() .HasMaxLength(50) @@ -984,6 +1077,17 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Navigation("Sensor"); }); + modelBuilder.Entity("garge_api.Models.Sensor.SensorActivity", b => + { + b.HasOne("garge_api.Models.Sensor.Sensor", "Sensor") + .WithMany() + .HasForeignKey("SensorId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Sensor"); + }); + modelBuilder.Entity("garge_api.Models.Sensor.SensorData", b => { b.HasOne("garge_api.Models.Sensor.Sensor", "Sensor") @@ -995,6 +1099,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/Admin/User.cs b/Models/Admin/User.cs index 6f1c88b..af4eeb0 100644 --- a/Models/Admin/User.cs +++ b/Models/Admin/User.cs @@ -13,4 +13,5 @@ public class User : IdentityUser public DateTime? EmailVerificationCodeExpiration { get; set; } public string? PasswordResetCodeHash { get; set; } public DateTime? PasswordResetCodeExpiration { get; set; } + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; } diff --git a/Models/ApplicationDbContext.cs b/Models/ApplicationDbContext.cs index fcd9079..2fccb20 100644 --- a/Models/ApplicationDbContext.cs +++ b/Models/ApplicationDbContext.cs @@ -25,6 +25,7 @@ public partial class ApplicationDbContext(DbContextOptions public DbSet WebhookSubscriptions { get; set; } public DbSet RefreshTokens { get; set; } public DbSet UserSensorCustomNames { get; set; } + public DbSet SensorActivities { get; set; } public DbSet EMQXMqttUsers { get; set; } public DbSet EMQXMqttAcls { get; set; } public DbSet DiscoveredDevices { get; set; } @@ -36,6 +37,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) { @@ -86,6 +88,18 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) .WithMany() .HasForeignKey(x => x.SensorId); + modelBuilder.Entity() + .HasOne(sa => sa.Sensor) + .WithMany() + .HasForeignKey(sa => sa.SensorId) + .OnDelete(DeleteBehavior.Cascade); + + modelBuilder.Entity() + .HasIndex(sa => sa.SensorId); + + modelBuilder.Entity() + .HasIndex(sa => sa.ActivityDate); + OnModelCreatingPartial(modelBuilder); modelBuilder.Entity() @@ -173,6 +187,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/Automation/AutomationRule.cs b/Models/Automation/AutomationRule.cs index 7800df3..d85f38a 100644 --- a/Models/Automation/AutomationRule.cs +++ b/Models/Automation/AutomationRule.cs @@ -40,5 +40,7 @@ public class AutomationRule // Optional timed action: keep socket ON for this many hours then auto-off public double? TimerDurationHours { get; set; } public DateTime? TimerActivatedAt { get; set; } + + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; } } diff --git a/Models/Sensor/Sensor.cs b/Models/Sensor/Sensor.cs index c47e2d3..c6b20ba 100644 --- a/Models/Sensor/Sensor.cs +++ b/Models/Sensor/Sensor.cs @@ -32,5 +32,7 @@ public class Sensor [Required] public required string ParentName { get; set; } + + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; } } diff --git a/Models/Sensor/SensorActivity.cs b/Models/Sensor/SensorActivity.cs new file mode 100644 index 0000000..834d26e --- /dev/null +++ b/Models/Sensor/SensorActivity.cs @@ -0,0 +1,37 @@ +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; +using Swashbuckle.AspNetCore.Annotations; + +namespace garge_api.Models.Sensor +{ + public class SensorActivity + { + [Key] + [DatabaseGenerated(DatabaseGeneratedOption.Identity)] + [SwaggerSchema(ReadOnly = true)] + public int Id { get; set; } + + [Required] + public int SensorId { get; set; } + + [ForeignKey("SensorId")] + public Sensor? Sensor { get; set; } + + [Required] + public string UserId { get; set; } = default!; + + [Required] + [MaxLength(100)] + public required string Title { get; set; } + + public string? Notes { get; set; } + + [Required] + public DateTime ActivityDate { get; set; } + + [Required] + public DateTime CreatedAt { get; set; } + + public DateTime? UpdatedAt { get; set; } + } +} 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; } + } +} diff --git a/Models/Switch/Switch.cs b/Models/Switch/Switch.cs index 936c0f9..b08d863 100644 --- a/Models/Switch/Switch.cs +++ b/Models/Switch/Switch.cs @@ -25,5 +25,7 @@ public class Switch [MaxLength(20)] public string? RegistrationCode { get; set; } + + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; } } diff --git a/Profiles/MappingProfile.cs b/Profiles/MappingProfile.cs index 48854d5..787c836 100644 --- a/Profiles/MappingProfile.cs +++ b/Profiles/MappingProfile.cs @@ -35,6 +35,9 @@ public MappingProfile() CreateMap().ReverseMap(); CreateMap().ReverseMap(); CreateMap(); + CreateMap().ReverseMap(); + CreateMap(); + CreateMap(); // Switch mappings CreateMap().ReverseMap(); diff --git a/Program.cs b/Program.cs index 589ae8e..d0a94af 100644 --- a/Program.cs +++ b/Program.cs @@ -203,6 +203,11 @@ public static async Task Main(string[] args) } await context.SaveChangesAsync(); + + if (app.Environment.IsDevelopment()) + { + await DevDataSeeder.SeedAsync(context, logger); + } } app.UseResponseCompression(); diff --git a/Services/DevDataSeeder.cs b/Services/DevDataSeeder.cs new file mode 100644 index 0000000..d8cec8d --- /dev/null +++ b/Services/DevDataSeeder.cs @@ -0,0 +1,136 @@ +using System.Globalization; +using garge_api.Models; +using garge_api.Models.Sensor; +using Microsoft.EntityFrameworkCore; + +namespace garge_api.Services +{ + /// + /// Seeds dummy data for local development so the app has something to render + /// before any real devices have been registered. + /// + public static class DevDataSeeder + { + // Each entry becomes one motorcycle voltmeter sensor. + // The Name format follows the existing convention __ + // so GenerateDefaultName/GenerateParentName work the same as for real sensors. + private static readonly (string Code, string DisplayName, double IdleVolts)[] DummyBikes = + { + ("MC1A2B", "MT-07", 12.6), + ("MC3C4D", "Tenere 700", 12.4), + ("MC5E6F", "DR-Z 400", 12.8), + }; + + public static async Task SeedAsync( + ApplicationDbContext context, + ILogger logger, + CancellationToken ct = default) + { + // Only seed when there are no sensors at all — never overwrite real data. + var anySensors = await context.Sensors.AnyAsync(ct); + if (anySensors) + { + logger.LogInformation("DevDataSeeder skipped: sensors already exist."); + await EnsureAccessForAllUsersAsync(context, logger, ct); + return; + } + + logger.LogInformation("DevDataSeeder: inserting {Count} dummy motorcycle voltmeters.", DummyBikes.Length); + + var random = new Random(42); + var now = DateTime.UtcNow; + + foreach (var (code, displayName, idle) in DummyBikes) + { + // Name pattern matches CreateSensor's expectations: __ + var name = $"mc_{code}_voltage"; + var sensor = new Sensor + { + Name = name, + Type = "voltage", + Role = name, + RegistrationCode = $"DEV{code}", // stable + obvious in logs + DefaultName = $"Garge {code} voltage", + ParentName = $"mc_{code}" + }; + context.Sensors.Add(sensor); + await context.SaveChangesAsync(ct); // need the generated Id for the data + custom name + + // Set a friendly per-sensor display name as a *global* default. Custom names are per-user + // (UserSensorCustomNames), so we instead just rely on DefaultName above and let the user + // rename it inside the app. We do, however, give the data a realistic shape: + var data = new List(); + // 24 hours of readings, one every ~10 minutes, with a slow daily droop + small noise + for (var i = 0; i < 24 * 6; i++) + { + var ts = now.AddMinutes(-i * 10); + // Idle sag: drop ~0.4V over 24h with sinusoidal jitter ±0.05V + var droop = (i / (24.0 * 6.0)) * 0.4; + var jitter = (random.NextDouble() - 0.5) * 0.1; + var v = idle - droop + jitter; + data.Add(new SensorData + { + SensorId = sensor.Id, + Value = Math.Round(v, 3).ToString(CultureInfo.InvariantCulture), + Timestamp = ts + }); + } + context.SensorData.AddRange(data); + logger.LogInformation("DevDataSeeder seeded {Display} ({Name}, code={Code})", displayName, name, sensor.RegistrationCode); + } + + await context.SaveChangesAsync(ct); + await EnsureAccessForAllUsersAsync(context, logger, ct); + logger.LogInformation("DevDataSeeder finished."); + } + + /// + /// Idempotently grants every existing user access to every dev-seeded sensor. + /// Runs every startup so a freshly-registered dev user gets the dummy sensors after a restart. + /// + private static async Task EnsureAccessForAllUsersAsync( + ApplicationDbContext context, + ILogger logger, + CancellationToken ct) + { + var devSensorIds = await context.Sensors + .Where(s => s.RegistrationCode.StartsWith("DEV")) + .Select(s => s.Id) + .ToListAsync(ct); + + if (devSensorIds.Count == 0) return; + + var userIds = await context.Users.Select(u => u.Id).ToListAsync(ct); + if (userIds.Count == 0) + { + logger.LogInformation("DevDataSeeder: no users yet — register a user, then restart the API to gain access."); + return; + } + + var existing = await context.UserSensors + .Where(us => devSensorIds.Contains(us.SensorId)) + .Select(us => new { us.UserId, us.SensorId }) + .ToListAsync(ct); + var existingSet = existing.Select(x => (x.UserId, x.SensorId)).ToHashSet(); + + var toAdd = new List(); + foreach (var userId in userIds) + { + foreach (var sensorId in devSensorIds) + { + if (!existingSet.Contains((userId, sensorId))) + { + toAdd.Add(new UserSensor { UserId = userId, SensorId = sensorId }); + } + } + } + + if (toAdd.Count > 0) + { + context.UserSensors.AddRange(toAdd); + await context.SaveChangesAsync(ct); + logger.LogInformation("DevDataSeeder granted {Count} new UserSensor mappings.", toAdd.Count); + } + } + } +} diff --git a/garge-api.csproj b/garge-api.csproj index 3c7f683..a89e8df 100644 --- a/garge-api.csproj +++ b/garge-api.csproj @@ -16,12 +16,12 @@ - + - - - - + + + + runtime; build; native; contentfiles; analyzers; buildtransitive all