Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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)


Expand Down
227 changes: 227 additions & 0 deletions Controllers/SensorActivitiesController.cs
Original file line number Diff line number Diff line change
@@ -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<SensorActivitiesController> _logger;
private static readonly List<string> AdminRoles = new() { "SensorAdmin", "admin" };

public SensorActivitiesController(
ApplicationDbContext context,
IMapper mapper,
ILogger<SensorActivitiesController> 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<bool> 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);
}

/// <summary>
/// Lists activities logged for a sensor (e.g. motorcycle voltmeter), most recent first.
/// </summary>
[HttpGet]
[SwaggerOperation(Summary = "Lists activities for a sensor.")]
[SwaggerResponse(200, "The list of activities.", typeof(IEnumerable<SensorActivityDto>))]
[SwaggerResponse(404, "Sensor not found.")]
[SwaggerResponse(403, "User does not have access to this sensor.")]
public async Task<IActionResult> 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<IEnumerable<SensorActivityDto>>(activities);
return Ok(dtos);
}

/// <summary>
/// Retrieves a single activity by id.
/// </summary>
[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<IActionResult> 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<SensorActivityDto>(activity));
}

/// <summary>
/// Creates a new activity entry for a sensor.
/// </summary>
[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<IActionResult> 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<SensorActivityDto>(activity);
_logger.LogInformation("Activity created: {@LogData}", new { activity.Id, sensorId });
return CreatedAtAction(nameof(GetActivity), new { sensorId, activityId = activity.Id }, resultDto);
}

/// <summary>
/// Updates an existing activity. Only the user who created it (or an admin) may update.
/// </summary>
[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<IActionResult> 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<SensorActivityDto>(activity));
}

/// <summary>
/// Deletes an activity. Only the user who created it (or an admin) may delete.
/// </summary>
[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<IActionResult> 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();
}
}
}
18 changes: 18 additions & 0 deletions Dtos/Sensor/CreateSensorActivityDto.cs
Original file line number Diff line number Diff line change
@@ -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; }

/// <summary>
/// When the activity occurred. Defaults to the current UTC time if not supplied.
/// </summary>
public DateTime? ActivityDate { get; set; }
}
}
14 changes: 14 additions & 0 deletions Dtos/Sensor/SensorActivityDto.cs
Original file line number Diff line number Diff line change
@@ -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; }
}
}
15 changes: 15 additions & 0 deletions Dtos/Sensor/UpdateSensorActivityDto.cs
Original file line number Diff line number Diff line change
@@ -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; }
}
}
13 changes: 13 additions & 0 deletions Models/ApplicationDbContext.cs
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ public partial class ApplicationDbContext(DbContextOptions<ApplicationDbContext>
public DbSet<WebhookSubscription> WebhookSubscriptions { get; set; }
public DbSet<RefreshToken> RefreshTokens { get; set; }
public DbSet<UserSensorCustomName> UserSensorCustomNames { get; set; }
public DbSet<SensorActivity> SensorActivities { get; set; }
public DbSet<EMQXMqttUser> EMQXMqttUsers { get; set; }
public DbSet<EMQXMqttAcl> EMQXMqttAcls { get; set; }
public DbSet<DiscoveredDevice> DiscoveredDevices { get; set; }
Expand Down Expand Up @@ -86,6 +87,18 @@ protected override void OnModelCreating(ModelBuilder modelBuilder)
.WithMany()
.HasForeignKey(x => x.SensorId);

modelBuilder.Entity<SensorActivity>()
.HasOne(sa => sa.Sensor)
.WithMany()
.HasForeignKey(sa => sa.SensorId)
.OnDelete(DeleteBehavior.Cascade);

modelBuilder.Entity<SensorActivity>()
.HasIndex(sa => sa.SensorId);

modelBuilder.Entity<SensorActivity>()
.HasIndex(sa => sa.ActivityDate);

OnModelCreatingPartial(modelBuilder);

modelBuilder.Entity<AutomationRule>()
Expand Down
37 changes: 37 additions & 0 deletions Models/Sensor/SensorActivity.cs
Original file line number Diff line number Diff line change
@@ -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; }
}
}
3 changes: 3 additions & 0 deletions Profiles/MappingProfile.cs
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,9 @@ public MappingProfile()
CreateMap<SensorData, SensorDataDto>().ReverseMap();
CreateMap<BatteryHealth, BatteryHealthDto>().ReverseMap();
CreateMap<CreateBatteryHealthDto, BatteryHealth>();
CreateMap<SensorActivity, SensorActivityDto>().ReverseMap();
CreateMap<CreateSensorActivityDto, SensorActivity>();
CreateMap<UpdateSensorActivityDto, SensorActivity>();

// Switch mappings
CreateMap<Switch, SwitchDto>().ReverseMap();
Expand Down
5 changes: 5 additions & 0 deletions Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
Loading