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
508 changes: 508 additions & 0 deletions .cursor/plans/onevo_system_audit_ebd9d4dc.plan.md

Large diffs are not rendered by default.

6 changes: 6 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,8 @@ SEED_ADMIN_EMAIL=admin@onevo.local
SEED_ADMIN_PASSWORD=Admin123!
# API key that connectors use to register (dev convenience)
CONNECTOR_BOOTSTRAP_KEY=dev-connector-bootstrap-key
# Dedicated key for cloud-ai worker (set separately in production)
CLOUD_AI_SERVICE_KEY=dev-cloud-ai-service-key
# Must match connector/installer/onevo-connector.iss AppVersion and connector app/config.py version
CONNECTOR_INSTALLER_VERSION=1.1.5
CONNECTOR_INSTALLER_URL=https://installer-site-one.vercel.app/ONEVO-Connector-Setup-1.1.5.exe
Expand All @@ -50,6 +52,10 @@ DASHBOARD_BASE_URL=http://localhost:4200
# ---- Connector (edge) ----
CONNECTOR_BACKEND_URL=http://localhost:8081
CONNECTOR_ADMIN_PORT=8099
# Admin UI binds to 127.0.0.1 by default; set CONNECTOR_ADMIN_BIND_LAN=true to expose on LAN
CONNECTOR_ADMIN_BIND_LAN=false
# Optional token required for admin UI/API (X-Admin-Token header or ?admin_token=)
CONNECTOR_ADMIN_TOKEN=

# ---- ONVIF (optional — set CONNECTOR_ONVIF_HOST to enable) ----
# Leave blank to use --source (manual RTSP URL or test video) instead.
Expand Down
23 changes: 23 additions & 0 deletions backend/Auth/ServiceAuth.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
namespace Onevo.Api.Auth;

/// <summary>Shared service-key validation for cloud-ai and connector bootstrap endpoints.</summary>
public static class ServiceAuth
{
public const string DefaultBootstrapKey = "dev-connector-bootstrap-key";

/// <summary>Key used by edge connectors to register (bootstrap only).</summary>
public static string ConnectorBootstrapKey(IConfiguration cfg)
=> cfg["Seed:ConnectorBootstrapKey"] ?? DefaultBootstrapKey;

/// <summary>Key used by the cloud-ai worker for ingest and zone reads.</summary>
public static string CloudAiServiceKey(IConfiguration cfg)
=> cfg["CloudAi:ServiceKey"]
?? cfg["Seed:CloudAiServiceKey"]
?? ConnectorBootstrapKey(cfg);

public static bool ValidateCloudAiKey(IConfiguration cfg, string? provided)
=> !string.IsNullOrEmpty(provided) && provided == CloudAiServiceKey(cfg);

public static bool ValidateBootstrapKey(IConfiguration cfg, string? provided)
=> !string.IsNullOrEmpty(provided) && provided == ConnectorBootstrapKey(cfg);
}
40 changes: 39 additions & 1 deletion backend/Contracts/Dtos.cs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ namespace Onevo.Api.Contracts;

// ---- Auth ----
public record LoginRequest(string Email, string Password);
public record ChangePasswordRequest(string CurrentPassword, string NewPassword);
public record LoginResponse(string Token, string Email, string Role, Guid? StoreId);

// ---- Users ----
Expand Down Expand Up @@ -47,7 +48,13 @@ public record UpdateZoneRequest(string? Name, string? ZoneType, string? PolygonJ
// ---- Connectors ----
public record RegisterConnectorRequest(Guid StoreId, string Name, string Version, string BootstrapKey);
public record RegisterConnectorResponse(Guid ConnectorId, string ApiKey);
public record HeartbeatRequest(double DiskFreePct, int UploadQueueDepth, string? DegradedReason, string Version);
public record HeartbeatRequest(
double DiskFreePct,
int UploadQueueDepth,
string? DegradedReason,
string Version,
string? AdminHost = null,
int? AdminPort = null);
public record CreateSetupCodeRequest(Guid StoreId);
public record CreateSetupCodeResponse(string Code, Guid StoreId, DateTimeOffset ExpiresAt);
public record ClaimSetupCodeRequest(string SetupCode, string Name, string Version);
Expand Down Expand Up @@ -113,6 +120,33 @@ public record ClipDetailResponse(

public record PipelineHealthResponse(int RedisQueueDepth, int FailedJobs);

public record AnalyticsSummaryResponse(
int TotalAlerts,
int PendingAlerts,
int HighRiskAlerts,
int MediumRiskAlerts,
int FalsePositives,
int TotalClips,
int AnalyzedClips,
Dictionary<string, int> AlertsByType);

public record ConnectorLogEntry(
Guid Id,
Guid StoreId,
string Name,
string Status,
string Version,
DateTimeOffset? LastHeartbeat,
string? DegradedReason,
int UploadQueueDepth,
double DiskFreePct);

public record SystemLogsResponse(
List<ConnectorLogEntry> Connectors,
int RedisQueueDepth,
int FailedJobs,
DateTimeOffset GeneratedAt);

// ---- AI events (posted by cloud-ai worker) ----
public record AiEventDto(
int TrackId,
Expand All @@ -129,3 +163,7 @@ public record AiEventsBatchRequest(Guid ClipId, string ModelVersion, List<AiEven

// ---- Alerts / reviews ----
public record ReviewRequest(string Action, string? ReasonCode, string? Notes);

public record BulkDeleteRequest(Guid? StoreId, List<Guid>? Ids, bool DeleteAllInStore = false);

public record BulkDeleteResponse(int Deleted, int Skipped, List<string> Errors);
3 changes: 2 additions & 1 deletion backend/Controllers/AiEventsController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Onevo.Api.Contracts;
using Onevo.Api.Auth;
using Onevo.Api.Data;
using Onevo.Api.Domain;
using Onevo.Api.Services;
Expand Down Expand Up @@ -36,7 +37,7 @@ public AiEventsController(OnevoDbContext db, IConfiguration cfg, RiskEngine risk
[HttpPost]
public async Task<IActionResult> Ingest(AiEventsBatchRequest req)
{
var serviceKey = _cfg["Seed:ConnectorBootstrapKey"];
var serviceKey = ServiceAuth.CloudAiServiceKey(_cfg);
if (!Request.Headers.TryGetValue("X-Service-Key", out var provided) ||
string.IsNullOrEmpty(serviceKey) || provided.ToString() != serviceKey)
return Unauthorized();
Expand Down
59 changes: 59 additions & 0 deletions backend/Controllers/AlertsController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,13 @@ public async Task Stream(CancellationToken ct)
{
await foreach (var ev in reader.ReadAllAsync(ct))
{
if (!TenantAccess.CanAccessStore(User, ev.StoreId))
continue;

var store = await _db.Stores.AsNoTracking().FirstOrDefaultAsync(s => s.Id == ev.StoreId, ct);
if (store is not null && !IsVisible(store.AlertVisibilityMode, TenantAccess.CurrentRole(User)))
continue;

var json = JsonSerializer.Serialize(new
{
alertId = ev.AlertId,
Expand Down Expand Up @@ -194,6 +201,58 @@ public async Task<IActionResult> Review(Guid id, ReviewRequest req)
return Ok(alert);
}

[Authorize(Roles = "Admin,Manager")]
[HttpPost("bulk-delete")]
public async Task<ActionResult<BulkDeleteResponse>> BulkDelete(BulkDeleteRequest req)
{
if (req.DeleteAllInStore)
{
if (req.StoreId is null)
return BadRequest(new { error = "storeId is required for delete all" });
if (!TenantAccess.CanAccessStore(User, req.StoreId.Value))
return Forbid();
}

var alerts = await ResolveAlertsForDeleteAsync(req);
if (alerts is null)
return BadRequest(new { error = "Provide ids or set deleteAllInStore with storeId" });

if (alerts.Count == 0)
return Ok(new BulkDeleteResponse(0, 0, []));

foreach (var alert in alerts)
{
_db.AlertReviews.RemoveRange(alert.Reviews);
_db.Alerts.Remove(alert);
}

await _db.SaveChangesAsync();
return Ok(new BulkDeleteResponse(alerts.Count, 0, []));
}

private async Task<List<Alert>?> ResolveAlertsForDeleteAsync(BulkDeleteRequest req)
{
IQueryable<Alert> query = TenantAccess.ScopeAlerts(_db.Alerts.Include(a => a.Reviews), User);

if (req.DeleteAllInStore)
{
if (req.StoreId is null) return null;
if (!TenantAccess.CanAccessStore(User, req.StoreId.Value)) return null;
query = query.Where(a => a.StoreId == req.StoreId);
}
else if (req.Ids is { Count: > 0 })
{
var ids = req.Ids.Distinct().ToList();
query = query.Where(a => ids.Contains(a.Id));
}
else
{
return null;
}

return await query.ToListAsync();
}

private static bool IsVisible(AlertVisibilityMode mode, UserRole role) => mode switch
{
AlertVisibilityMode.All => true,
Expand Down
58 changes: 58 additions & 0 deletions backend/Controllers/AnalyticsController.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Onevo.Api.Auth;
using Onevo.Api.Contracts;
using Onevo.Api.Data;
using Onevo.Api.Domain;

namespace Onevo.Api.Controllers;

[ApiController]
[Authorize]
[Route("api/analytics")]
public class AnalyticsController : ControllerBase
{
private readonly OnevoDbContext _db;

public AnalyticsController(OnevoDbContext db) => _db = db;

[HttpGet("summary")]
public async Task<ActionResult<AnalyticsSummaryResponse>> Summary([FromQuery] Guid? storeId)
{
if (storeId is not null && !TenantAccess.CanAccessStore(User, storeId.Value))
return Forbid();

var alerts = TenantAccess.ScopeAlerts(_db.Alerts, User);
IQueryable<Clip> clips = from c in _db.Clips
join cam in TenantAccess.ScopeCameras(_db.Cameras, User)
on c.CameraId equals cam.Id
select c;

if (storeId is not null)
{
alerts = alerts.Where(a => a.StoreId == storeId);
clips = from c in clips
join cam in _db.Cameras on c.CameraId equals cam.Id
where cam.StoreId == storeId
select c;
}

var alertRows = await alerts.AsNoTracking().ToListAsync();
var clipCount = await clips.CountAsync();
var analyzedClips = await clips.CountAsync(c => c.Status == ClipStatus.Analyzed);

return Ok(new AnalyticsSummaryResponse(
TotalAlerts: alertRows.Count,
PendingAlerts: alertRows.Count(a => a.Status == AlertStatus.PendingReview),
HighRiskAlerts: alertRows.Count(a => a.RiskLevel == RiskLevel.High),
MediumRiskAlerts: alertRows.Count(a => a.RiskLevel == RiskLevel.Medium),
FalsePositives: alertRows.Count(a => a.Status == AlertStatus.FalsePositive),
TotalClips: clipCount,
AnalyzedClips: analyzedClips,
AlertsByType: alertRows
.GroupBy(a => a.AlertType)
.ToDictionary(g => g.Key, g => g.Count())
));
Comment on lines +41 to +56

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Remediation recommended

6. Analytics loads alerts in-memory 🐞 Bug ➹ Performance

AnalyticsController materializes all scoped alerts with ToListAsync and computes counts/grouping
in-process. This will become slow and memory-heavy as alert history grows, even though all metrics
can be computed via SQL aggregates.
Agent Prompt
### Issue description
The summary endpoint loads all matching `Alert` rows into memory to compute totals and groupings, which scales poorly.

### Issue Context
This endpoint is intended for an ongoing dashboard view and will grow with time.

### Fix Focus Areas
- backend/Controllers/AnalyticsController.cs[26-56]

### Concrete fix
- Replace `ToListAsync` with EF aggregate queries:
  - `TotalAlerts = await alerts.CountAsync()`
  - `PendingAlerts = await alerts.CountAsync(a => a.Status == ...)`
  - `AlertsByType = await alerts.GroupBy(a => a.AlertType).Select(g => new { ... }).ToDictionaryAsync(...)`
- Consider running counts concurrently (multiple tasks) if the DB can handle it.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

}
}
19 changes: 19 additions & 0 deletions backend/Controllers/AuthController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -31,4 +31,23 @@ public async Task<ActionResult<LoginResponse>> Login(LoginRequest req)
var token = _jwt.CreateToken(user);
return new LoginResponse(token, user.Email, user.Role.ToString(), user.StoreId);
}

[Authorize]
[HttpPost("change-password")]
public async Task<IActionResult> ChangePassword(ChangePasswordRequest req)
{
if (string.IsNullOrWhiteSpace(req.NewPassword) || req.NewPassword.Length < 8)
return BadRequest(new { error = "New password must be at least 8 characters" });

var userId = TenantAccess.CurrentUserId(User);
var user = await _db.Users.FindAsync(userId);
if (user is null) return Unauthorized();

if (!BCrypt.Net.BCrypt.Verify(req.CurrentPassword, user.PasswordHash))
return BadRequest(new { error = "Current password is incorrect" });

user.PasswordHash = BCrypt.Net.BCrypt.HashPassword(req.NewPassword);
await _db.SaveChangesAsync();
return Ok(new { ok = true });
}
}
78 changes: 75 additions & 3 deletions backend/Controllers/ClipsController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -246,6 +246,78 @@ join store in _db.Stores on cam.StoreId equals store.Id
[Authorize(Roles = "Admin,Manager")]
[HttpDelete("{id:guid}")]
public async Task<IActionResult> Delete(Guid id)
{
var result = await TryDeleteClipAsync(id);
return result switch
{
DeleteClipResult.Deleted => Ok(new { ok = true, clipId = id }),
DeleteClipResult.NotFound => NotFound(),
DeleteClipResult.SkippedConfirmed => Conflict(new { error = "Cannot delete clip linked to a confirmed alert" }),
_ => NotFound(),
};
}

[Authorize(Roles = "Admin,Manager")]
[HttpPost("bulk-delete")]
public async Task<ActionResult<BulkDeleteResponse>> BulkDelete(BulkDeleteRequest req)
{
if (req.DeleteAllInStore)
{
if (req.StoreId is null)
return BadRequest(new { error = "storeId is required for delete all" });
if (!TenantAccess.CanAccessStore(User, req.StoreId.Value))
return Forbid();
}

var targetIds = await ResolveClipDeleteIdsAsync(req);
if (targetIds is null)
return BadRequest(new { error = "Provide ids or set deleteAllInStore with storeId" });

var deleted = 0;
var skipped = 0;
var errors = new List<string>();

foreach (var id in targetIds.Distinct())
{
var result = await TryDeleteClipAsync(id);
switch (result)
{
case DeleteClipResult.Deleted:
deleted++;
break;
case DeleteClipResult.SkippedConfirmed:
skipped++;
break;
case DeleteClipResult.NotFound:
break;
}
}

return Ok(new BulkDeleteResponse(deleted, skipped, errors));
}
Comment on lines +276 to +297

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Remediation recommended

7. Bulk clip delete hides failures 🐞 Bug ☼ Reliability

ClipsController bulk-delete returns an errors array but never populates it and does not isolate
per-clip exceptions, so a single S3/DB failure can abort the whole request with a 500. The response
also omits any accounting for NotFound clip IDs, making bulk results hard to interpret.
Agent Prompt
### Issue description
Bulk delete currently does not report which clip IDs failed and can fail the entire operation on the first exception.

### Issue Context
`TryDeleteClipAsync` performs multiple external operations (S3 delete + DB changes) that can throw.

### Fix Focus Areas
- backend/Controllers/ClipsController.cs[260-297]
- backend/Controllers/ClipsController.cs[320-354]

### Concrete fix
- Wrap each `TryDeleteClipAsync(id)` call in try/catch; on exception append a human-readable entry to `errors` and continue.
- Track and return an explicit `notFound` count (or include a `missingIds` list) to make results deterministic.
- Optionally use a transaction for DB deletes per clip (keeping S3 deletion best-effort) and/or add a cancellation token to avoid long-running requests.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


private async Task<List<Guid>?> ResolveClipDeleteIdsAsync(BulkDeleteRequest req)
{
if (req.DeleteAllInStore)
{
if (req.StoreId is null) return null;
if (!TenantAccess.CanAccessStore(User, req.StoreId.Value)) return null;

return await (
from clip in _db.Clips
join cam in TenantAccess.ScopeCameras(_db.Cameras, User) on clip.CameraId equals cam.Id
where cam.StoreId == req.StoreId
select clip.Id
).ToListAsync();
}

if (req.Ids is { Count: > 0 }) return req.Ids;
return null;
}

private enum DeleteClipResult { Deleted, NotFound, SkippedConfirmed }

private async Task<DeleteClipResult> TryDeleteClipAsync(Guid id)
{
var row = await (
from clip in _db.Clips
Expand All @@ -254,14 +326,14 @@ join cam in TenantAccess.ScopeCameras(_db.Cameras, User) on clip.CameraId equals
select new { clip, cam }
).FirstOrDefaultAsync();

if (row is null) return NotFound();
if (row is null) return DeleteClipResult.NotFound;

var alert = await _db.Alerts
.Include(a => a.Reviews)
.FirstOrDefaultAsync(a => a.ClipId == id);

if (alert is not null && alert.Status == AlertStatus.Confirmed)
return Conflict(new { error = "Cannot delete clip linked to a confirmed alert" });
return DeleteClipResult.SkippedConfirmed;

if (!string.IsNullOrEmpty(row.clip.ObjectKey))
await _s3.DeleteAsync(row.clip.ObjectKey);
Expand All @@ -279,6 +351,6 @@ join cam in TenantAccess.ScopeCameras(_db.Cameras, User) on clip.CameraId equals

_db.Clips.Remove(row.clip);
await _db.SaveChangesAsync();
return Ok(new { ok = true, clipId = id });
return DeleteClipResult.Deleted;
}
}
Loading
Loading