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 Core/Resgrid.Services/Reporting/IncidentExport.cs
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,14 @@ private static string Utc(DateTime? value)
private static string Escape(string value)
{
value ??= string.Empty;

// A leading =, +, -, @, tab or CR makes Excel/Sheets evaluate the cell as a formula on
// import (CSV injection). Plain numeric values (e.g. "-122.5") are exempt so coordinates
// and phone numbers survive intact.
if (value.Length > 0 && (value[0] == '=' || value[0] == '+' || value[0] == '-' || value[0] == '@' || value[0] == '\t' || value[0] == '\r')
&& !double.TryParse(value, NumberStyles.Float, CultureInfo.InvariantCulture, out _))
value = "'" + value;

if (value.IndexOf('"') >= 0 || value.IndexOf(',') >= 0 || value.IndexOf('\n') >= 0 || value.IndexOf('\r') >= 0)
return "\"" + value.Replace("\"", "\"\"") + "\"";
return value;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1175,7 +1175,7 @@ from shifts sh
SelectAllOpenCallsByDidDateQuery =
"SELECT * FROM %SCHEMA%.%TABLENAME% WHERE DepartmentId = %DID% AND IsDeleted = false AND State = 0";
SelectAllCallsByDidLoggedOnQuery =
"SELECT * FROM %SCHEMA%.%TABLENAME% WHERE DepartmentId = %DID% AND AND LoggedOn >= %DATE%";
"SELECT * FROM %SCHEMA%.%TABLENAME% WHERE DepartmentId = %DID% AND LoggedOn >= %DATE%";

// ----- Platform reporting / analytics (set-based; %ALLDEPTS% = 1 means system-wide) -----
SelectReportCallsCountQuery =
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1128,7 +1128,7 @@ FROM [dbo].[Shifts] sh
SelectAllOpenCallsByDidDateQuery =
"SELECT * FROM %SCHEMA%.%TABLENAME% WHERE [DepartmentId] = %DID% AND [IsDeleted] = 0 AND [State] = 0";
SelectAllCallsByDidLoggedOnQuery =
"SELECT * FROM %SCHEMA%.%TABLENAME% WHERE [DepartmentId] = %DID% AND AND [LoggedOn] >= %DATE%";
"SELECT * FROM %SCHEMA%.%TABLENAME% WHERE [DepartmentId] = %DID% AND [LoggedOn] >= %DATE%";

// ----- Platform reporting / analytics (set-based; %ALLDEPTS% = 1 means system-wide) -----
SelectReportCallsCountQuery =
Expand Down
19 changes: 19 additions & 0 deletions Tests/Resgrid.Tests/Services/IncidentExportTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,25 @@ public void BuildCsv_writes_header_and_escapes_values()
csv.Should().Contain("2026-06-01T12:00:00Z");
}

[Test]
public void BuildCsv_neutralizes_leading_formula_characters()
{
var call = SampleCall();
call.Name = "=HYPERLINK(\"http://evil.example\",\"click\")";
call.NatureOfCall = "@cmd|' /C calc'!A0";
call.Address = "-122.5";

var bytes = IncidentExport.BuildCsv(ExportProfile.Generic, new[] { call });
var csv = Encoding.UTF8.GetString(bytes);

// Formula-leading cells are neutralized with a single-quote prefix.
csv.Should().Contain("'=HYPERLINK");
csv.Should().Contain("'@cmd");
csv.Should().NotContain(",=HYPERLINK");
// Plain negative numbers are exempt from neutralization.
csv.Should().Contain(",-122.5,");
}

[Test]
public void BuildCsv_nfirs_emits_full_schema_header_with_empty_gap_cells()
{
Expand Down
18 changes: 11 additions & 7 deletions Web/Resgrid.Web.Services/Controllers/v4/ReportingController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
using Microsoft.AspNetCore.Mvc;
using Resgrid.Model.Reporting;
using Resgrid.Model.Services;
using Resgrid.Providers.Claims;
using Resgrid.Web.Services.Helpers;
using Resgrid.Web.Services.Models.v4.Reporting;

Expand All @@ -18,6 +19,7 @@ namespace Resgrid.Web.Services.Controllers.v4
///
/// SECURITY: every endpoint is hard-scoped to the authenticated user's claim DepartmentId — there is
/// deliberately no departmentId parameter, so a client can never request another department's data.
/// Every endpoint also requires the caller's Reports/View permission (Reports_View policy).
/// System-wide (cross-department) reporting is intentionally NOT exposed over HTTP; it is available
/// only to the in-process BackOffice, which resolves IPlatformReportingService directly and calls it
/// with departmentId = null.
Expand All @@ -29,6 +31,7 @@ public class ReportingController : V4AuthenticatedApiControllerbase
{
private const int MaxDayWindow = 366;
private const int MaxMonthWindowDays = 366 * 5;
private const int MaxTopN = 50;

private readonly IPlatformReportingService _reportingService;

Expand All @@ -44,14 +47,15 @@ public ReportingController(IPlatformReportingService reportingService)
/// <param name="from">Window start (UTC).</param>
/// <param name="to">Window end (UTC).</param>
/// <param name="granularity">Series bucketing: 0 = day, 1 = month.</param>
/// <param name="topN">Max slices per breakdown before an "Other" bucket.</param>
/// <param name="topN">Max slices per breakdown before an "Other" bucket (clamped to 1–50).</param>
[HttpGet("GetDashboard")]
[ProducesResponseType(StatusCodes.Status200OK)]
[Authorize]
[Authorize(Policy = ResgridResources.Reports_View)]
public async Task<ActionResult<DashboardReportResult>> GetDashboard(DateTime from, DateTime to,
int granularity = 0, int topN = 5, CancellationToken cancellationToken = default)
{
var gran = granularity == 1 ? ReportGranularity.Month : ReportGranularity.Day;
topN = Math.Clamp(topN, 1, MaxTopN);
var (startUtc, endUtc) = NormalizeWindow(from, to, gran == ReportGranularity.Month ? MaxMonthWindowDays : MaxDayWindow);

var report = await _reportingService.GetDashboardReportAsync(DepartmentId, startUtc, endUtc, gran, topN, false, cancellationToken);
Expand All @@ -65,7 +69,7 @@ public async Task<ActionResult<DashboardReportResult>> GetDashboard(DateTime fro
/// <summary>Response-time / NFPA analytics (alarm handling, turnout, travel, total response).</summary>
[HttpGet("GetResponseTimes")]
[ProducesResponseType(StatusCodes.Status200OK)]
[Authorize]
[Authorize(Policy = ResgridResources.Reports_View)]
public async Task<ActionResult<ResponseTimeReportResult>> GetResponseTimes(DateTime from, DateTime to,
CancellationToken cancellationToken = default)
{
Expand All @@ -81,7 +85,7 @@ public async Task<ActionResult<ResponseTimeReportResult>> GetResponseTimes(DateT
/// <summary>Unit Hour Utilization and workload analytics.</summary>
[HttpGet("GetUtilization")]
[ProducesResponseType(StatusCodes.Status200OK)]
[Authorize]
[Authorize(Policy = ResgridResources.Reports_View)]
public async Task<ActionResult<UtilizationReportResult>> GetUtilization(DateTime from, DateTime to,
CancellationToken cancellationToken = default)
{
Expand All @@ -97,7 +101,7 @@ public async Task<ActionResult<UtilizationReportResult>> GetUtilization(DateTime
/// <summary>Personnel participation and certification-compliance analytics.</summary>
[HttpGet("GetParticipation")]
[ProducesResponseType(StatusCodes.Status200OK)]
[Authorize]
[Authorize(Policy = ResgridResources.Reports_View)]
public async Task<ActionResult<ParticipationReportResult>> GetParticipation(DateTime from, DateTime to,
CancellationToken cancellationToken = default)
{
Expand All @@ -116,7 +120,7 @@ public async Task<ActionResult<ParticipationReportResult>> GetParticipation(Date
/// </summary>
[HttpGet("ExportIncidents")]
[ProducesResponseType(StatusCodes.Status200OK)]
[Authorize]
[Authorize(Policy = ResgridResources.Reports_View)]
public async Task<IActionResult> ExportIncidents(DateTime from, DateTime to, int profile = 0,
CancellationToken cancellationToken = default)
{
Expand All @@ -134,7 +138,7 @@ public async Task<IActionResult> ExportIncidents(DateTime from, DateTime to, int
/// </summary>
[HttpGet("GetExportGaps")]
[ProducesResponseType(StatusCodes.Status200OK)]
[Authorize]
[Authorize(Policy = ResgridResources.Reports_View)]
public ActionResult<ExportGapReportResult> GetExportGaps(int profile = 0)
{
var exportProfile = Enum.IsDefined(typeof(ExportProfile), profile) ? (ExportProfile)profile : ExportProfile.Generic;
Expand Down
3 changes: 2 additions & 1 deletion Web/Resgrid.Web.Services/Resgrid.Web.Services.xml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading