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
4 changes: 2 additions & 2 deletions .github/workflows/docker.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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:
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
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
105 changes: 102 additions & 3 deletions Controllers/AdminController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
using Swashbuckle.AspNetCore.Annotations;
using AutoMapper;
using garge_api.Models.Admin;
using Microsoft.EntityFrameworkCore;

namespace garge_api.Controllers
{
Expand Down Expand Up @@ -200,21 +201,119 @@ public async Task<IActionResult> RemoveRole([FromRoute] string roleName, [FromQu
}

/// <summary>
/// Gets all users.
/// Gets all users with their roles.
/// </summary>
/// <returns>A list of all users.</returns>
[HttpGet("/api/users")]
[SwaggerOperation(Summary = "Gets all users.")]
[SwaggerResponse(200, "Users retrieved successfully.", typeof(IEnumerable<UserDto>))]
public IActionResult GetUsers()
public async Task<IActionResult> GetUsers()
{
_logger.LogInformation("GetUsers called by {@LogData}", new { User = User.Identity?.Name });

var users = _userManager.Users.ToList();
var dtos = _mapper.Map<IEnumerable<UserDto>>(users);
var dtos = new List<UserDto>();
foreach (var user in users)
{
var dto = _mapper.Map<UserDto>(user);
dto.Roles = await _userManager.GetRolesAsync(user);
dtos.Add(dto);
}
return Ok(dtos);
}

/// <summary>
/// Gets aggregate platform stats.
/// </summary>
[HttpGet("/api/admin/stats")]
[SwaggerOperation(Summary = "Gets aggregate platform stats.")]
[SwaggerResponse(200, "Stats retrieved successfully.", typeof(AdminStatsDto))]
public async Task<IActionResult> 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);
}

/// <summary>
/// Gets all discovered MQTT devices.
/// </summary>
[HttpGet("/api/admin/devices")]
[SwaggerOperation(Summary = "Gets all discovered MQTT devices.")]
public async Task<IActionResult> 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);
}

/// <summary>
/// Gets cumulative daily stats over time for charting.
/// </summary>
[HttpGet("/api/admin/stats/history")]
[SwaggerOperation(Summary = "Gets cumulative daily stats over time.")]
public async Task<IActionResult> 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<object>());

var start = allDates.Min();
var today = DateTime.UtcNow.Date;

var result = new List<object>();
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);
}

/// <summary>
/// Deletes a user by their ID.
/// </summary>
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();
}
}
}
Loading
Loading