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/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/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/Models/ApplicationDbContext.cs b/Models/ApplicationDbContext.cs index fcd9079..4970c94 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; } @@ -86,6 +87,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() 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/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); + } + } + } +}