using Tau.Acuvim.Portal.Constants; using Tau.Acuvim.Portal.DTOs; using Tau.Acuvim.Portal.Services; namespace Tau.Acuvim.Portal.Endpoints; public static class MeasurementsEndpoints { private const string XlsxContentType = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"; public static IEndpointRouteBuilder MapMeasurementsEndpoints(this IEndpointRouteBuilder app) { var read = app.MapGroup("/api/measurements") .RequireAuthorization() .WithTags("Measurements"); read.MapGet("/", async ( Guid deviceId, DateTime from, DateTime to, string bucket, MeasurementQueryService svc, CancellationToken ct) => { if (!svc.IsValidBucket(bucket)) { return Results.BadRequest(new { error = "bucket must be one of: minute, 5min, 15min, hour, day." }); } if (to <= from) return Results.BadRequest(new { error = "'to' must be after 'from'." }); var rows = await svc.GetBucketsAsync(deviceId, from, to, bucket, ct); return Results.Ok(rows); }); // Raw measurement list (Measurements page). Optional comma-separated // deviceIds. Paginated; preview-friendly limits. read.MapGet("/raw", async ( DateTime from, DateTime to, string? deviceIds, int? limit, int? offset, DashboardSummaryService svc, CancellationToken ct) => { if (to <= from) return Results.BadRequest(new { error = "'to' must be after 'from'." }); if (!TryParseDeviceIds(deviceIds, out var ids, out var parseError)) { return Results.BadRequest(new { error = parseError }); } var resolvedLimit = limit is > 0 and <= 1000 ? limit.Value : 200; var resolvedOffset = offset is > 0 ? offset.Value : 0; var rows = await svc.ReadRawAsync(from, to, ids, resolvedLimit, resolvedOffset, ct); var total = await svc.CountRawAsync(from, to, ids, ct); return Results.Ok(new RawMeasurementsPage(total, resolvedLimit, resolvedOffset, rows)); }); // Raw measurement export. Same filters as /raw but no pagination — capped // at 250 000 rows for memory safety. read.MapGet("/raw/export.xlsx", async ( DateTime from, DateTime to, string? deviceIds, int? rowCap, DashboardSummaryService svc, ExcelExportService excel, CancellationToken ct) => { if (to <= from) return Results.BadRequest(new { error = "'to' must be after 'from'." }); if (!TryParseDeviceIds(deviceIds, out var ids, out var parseError)) { return Results.BadRequest(new { error = parseError }); } var cap = rowCap is > 0 and <= 250_000 ? rowCap.Value : 100_000; var rows = await svc.ReadRawAsync(from, to, ids, cap, offset: 0, ct); var bytes = excel.BuildRawMeasurements(rows, from, to); var meterSuffix = ids is { Count: 1 } ? "-1meter" : ids is { Count: > 1 } ? $"-{ids.Count}meters" : ""; var filename = $"measurements{meterSuffix}-{from:yyyyMMdd}-{to:yyyyMMdd}.xlsx"; return Results.File(bytes, XlsxContentType, filename); }); var ingest = app.MapGroup("/api/ingest") .RequireAuthorization(Policies.AdminOnly) .WithTags("Ingest"); ingest.MapPost("/measurements", async ( MeasurementIngestRow[] rows, MeasurementIngestService svc, CancellationToken ct) => { if (rows is null || rows.Length == 0) { return Results.BadRequest(new { error = "At least one row required." }); } var result = await svc.IngestAsync(rows, ct); return Results.Ok(result); }); return app; } private static bool TryParseDeviceIds(string? raw, out List? ids, out string? error) { ids = null; error = null; if (string.IsNullOrWhiteSpace(raw)) return true; var parts = raw.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); var parsed = new List(parts.Length); foreach (var p in parts) { if (!Guid.TryParse(p, out var g)) { error = $"deviceIds contains an invalid GUID: '{p}'."; return false; } parsed.Add(g); } ids = parsed; return true; } }