Skip to content
Merged
1 change: 1 addition & 0 deletions .vscode/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
"banktrackingfrontend",
"bankuser",
"codegen",
"Gridster",
"healthcheck",
"isready",
"xaxis",
Expand Down
2 changes: 2 additions & 0 deletions PhantomDave.BankTracking.Api/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ public static void Main(string[] args)
builder.Services.AddScoped<FinanceRecordService>();
builder.Services.AddScoped<FileImportService>();
builder.Services.AddScoped<ColumnDetectionService>();
builder.Services.AddScoped<DashboardService>();
builder.Services.AddHttpContextAccessor();
builder.Services.Configure<JwtSettings>(builder.Configuration.GetSection("Jwt"));
builder.Services.AddSingleton<IJwtTokenService, JwtTokenService>();
Expand Down Expand Up @@ -94,6 +95,7 @@ public static void Main(string[] args)
.AddMutationType()
.AddType<UploadType>()
.BindRuntimeType<RecurrenceFrequency, EnumType<RecurrenceFrequency>>()
.BindRuntimeType<WidgetType, EnumType<WidgetType>>()
.ModifyRequestOptions(options =>
{
options.IncludeExceptionDetails = builder.Environment.IsDevelopment();
Expand Down
216 changes: 216 additions & 0 deletions PhantomDave.BankTracking.Api/Services/DashboardService.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,216 @@
using Microsoft.EntityFrameworkCore;
using PhantomDave.BankTracking.Data.UnitOfWork;
using PhantomDave.BankTracking.Library.Models;

namespace PhantomDave.BankTracking.Api.Services;

public class DashboardService
{
private readonly IUnitOfWork _unitOfWork;

public DashboardService(IUnitOfWork unitOfWork)
{
_unitOfWork = unitOfWork;
}

public Task<Dashboard?> GetDashboardAsync(int id) =>
_unitOfWork.Dashboards.GetByIdAsync(id);

public async Task<IEnumerable<Dashboard>> GetAllDashboardsAsync()
{
var dashboards = await _unitOfWork.Dashboards
.Query()
.AsNoTracking()
.OrderBy(d => d.Id)
.ToListAsync();

foreach (var dashboard in dashboards)
{
var widgets = await _unitOfWork.DashboardWidgets
.Query()
.Where(w => w.DashboardId == dashboard.Id)
.OrderBy(w => w.Id)
.ToListAsync();
dashboard.Widgets = widgets.ToList();
}

Copy link

Copilot AI Nov 24, 2025

Choose a reason for hiding this comment

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

N+1 query problem: This method fetches dashboards in one query, then loops through each dashboard to fetch widgets individually. This results in 1 + N database queries. Consider using .Include(d => d.Widgets) to eager-load widgets in a single query for better performance.

Suggested change
.AsNoTracking()
.OrderBy(d => d.Id)
.ToListAsync();
foreach (var dashboard in dashboards)
{
var widgets = await _unitOfWork.DashboardWidgets
.Query()
.Where(w => w.DashboardId == dashboard.Id)
.OrderBy(w => w.Id)
.ToListAsync();
dashboard.Widgets = widgets.ToList();
}
.Include(d => d.Widgets)
.AsNoTracking()
.OrderBy(d => d.Id)
.ToListAsync();

Copilot uses AI. Check for mistakes.
return dashboards;
}

public async Task<Dashboard?> CreateDashboardAsync(string name)
{
if (string.IsNullOrWhiteSpace(name))
{
return null;
}

var normalizedName = NormalizeName(name);
if (normalizedName.Length == 0)
{
return null;
}

var dashboard = new Dashboard
{
Name = normalizedName,
Widgets = []
};

await _unitOfWork.Dashboards.AddAsync(dashboard);
await _unitOfWork.SaveChangesAsync();

return dashboard;
}

public async Task<Dashboard?> UpdateDashboardAsync(
int id,
string? name = null)
{
var dashboard = await _unitOfWork.Dashboards.GetByIdAsync(id);
if (dashboard is null)
{
return null;
}

if (!string.IsNullOrWhiteSpace(name))
{
var normalizedName = NormalizeName(name);
if (normalizedName.Length == 0)
{
return null;
}

dashboard.Name = normalizedName;
}

await _unitOfWork.Dashboards.UpdateAsync(dashboard);
await _unitOfWork.SaveChangesAsync();

return dashboard;
}

public async Task<bool> DeleteDashboardAsync(int id)
{
var deleted = await _unitOfWork.Dashboards.DeleteAsync(id);
if (!deleted)
{
return false;
}

await _unitOfWork.SaveChangesAsync();
return true;
}

public async Task<DashboardWidget?> AddWidgetAsync(
int dashboardId,
WidgetType type,
int x,
int y,
int rows,
int cols)
{
var dashboard = await _unitOfWork.Dashboards.GetByIdAsync(dashboardId);
if (dashboard is null)
{
return null;
}

if (rows <= 0 || cols <= 0)
{
return null;
}

var widget = new DashboardWidget
{
DashboardId = dashboardId,
Type = type,
X = Math.Max(0, x),
Y = Math.Max(0, y),
Rows = rows,
Cols = cols
};

await _unitOfWork.DashboardWidgets.AddAsync(widget);
await _unitOfWork.SaveChangesAsync();

return widget;
}

public async Task<DashboardWidget?> UpdateWidgetAsync(
int id,
WidgetType? type = null,
int? x = null,
int? y = null,
int? rows = null,
int? cols = null)
{
var widget = await _unitOfWork.DashboardWidgets.GetByIdAsync(id);
if (widget is null)
{
return null;
}

if (type.HasValue)
{
widget.Type = type.Value;
}

if (x.HasValue)
{
widget.X = Math.Max(0, x.Value);
}

if (y.HasValue)
{
widget.Y = Math.Max(0, y.Value);
}

if (rows.HasValue)
{
if (rows.Value <= 0)
{
return null;
}

widget.Rows = rows.Value;
}

if (cols.HasValue)
{
if (cols.Value <= 0)
{
return null;
}

widget.Cols = cols.Value;
}

await _unitOfWork.DashboardWidgets.UpdateAsync(widget);
await _unitOfWork.SaveChangesAsync();

return widget;
}

public async Task<bool> RemoveWidgetAsync(int id)
{
var deleted = await _unitOfWork.DashboardWidgets.DeleteAsync(id);
if (!deleted)
{
return false;
}

await _unitOfWork.SaveChangesAsync();
return true;
}

private static string NormalizeName(string name)
{
if (string.IsNullOrWhiteSpace(name))
{
return string.Empty;
}

var trimmed = name.Trim();
return trimmed.Length <= 100 ? trimmed : trimmed[..100];
}
}
4 changes: 2 additions & 2 deletions PhantomDave.BankTracking.Api/Services/FileImportService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,7 @@ private async Task<ParsedFileData> ParseXlsxAsync(Stream stream)
{
var parsedData = new ParsedFileData();

parsedData.Rows = new List<Dictionary<string, string>>();
parsedData.Rows = [];

using var package = new ExcelPackage(stream);
var worksheet = package.Workbook.Worksheets.FirstOrDefault();
Expand All @@ -114,7 +114,7 @@ private async Task<ParsedFileData> ParseXlsxAsync(Stream stream)

var headerRowIndex = DetectHeaderRow(worksheet);

parsedData.Headers = new List<string>();
parsedData.Headers = [];
for (var col = 1; col <= worksheet.Dimension.End.Column; col++)
{
var headerValue = worksheet.Cells[headerRowIndex, col].Text ?? $"Column{col}";
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -216,7 +216,7 @@ public async Task<List<FinanceRecord>> FindDuplicatesAsync(
var candidateKeys = CreateDuplicateKeys(candidates);
if (candidateKeys.Count == 0)
{
return new List<FinanceRecord>();
return [];
}

var allRecords = await _unitOfWork.FinanceRecords.Query()
Expand Down
13 changes: 13 additions & 0 deletions PhantomDave.BankTracking.Api/Types/Inputs/AddWidgetInput.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
using PhantomDave.BankTracking.Library.Models;

namespace PhantomDave.BankTracking.Api.Types.Inputs;

public sealed class AddWidgetInput
{
public int DashboardId { get; init; }
public WidgetType Type { get; init; }
public int X { get; init; }
public int Y { get; init; }
public int Rows { get; init; }
public int Cols { get; init; }
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
namespace PhantomDave.BankTracking.Api.Types.Inputs;

public sealed class CreateDashboardInput
{
public string? Name { get; init; }
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
namespace PhantomDave.BankTracking.Api.Types.Inputs;

public sealed class UpdateDashboardInput
{
public int Id { get; init; }
public string? Name { get; init; }
}
13 changes: 13 additions & 0 deletions PhantomDave.BankTracking.Api/Types/Inputs/UpdateWidgetInput.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
using PhantomDave.BankTracking.Library.Models;

namespace PhantomDave.BankTracking.Api.Types.Inputs;

public sealed class UpdateWidgetInput
{
public int Id { get; init; }
public WidgetType? Type { get; init; }
public int? X { get; init; }
public int? Y { get; init; }
public int? Rows { get; init; }
public int? Cols { get; init; }
}
81 changes: 81 additions & 0 deletions PhantomDave.BankTracking.Api/Types/Mutations/DashboardMutations.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
using HotChocolate;
using HotChocolate.Types;
using PhantomDave.BankTracking.Api.Types.Inputs;
using PhantomDave.BankTracking.Api.Types.ObjectTypes;
using PhantomDave.BankTracking.Data.UnitOfWork;
using PhantomDave.BankTracking.Library.Models;

namespace PhantomDave.BankTracking.Api.Types.Mutations;

[ExtendObjectType(OperationTypeNames.Mutation)]
public class DashboardMutations
{
public async Task<DashboardType> CreateDashboard(
[Service] IUnitOfWork unitOfWork,
[Service] IHttpContextAccessor httpContextAccessor,
CreateDashboardInput input)
{
var accountId = httpContextAccessor.GetAccountIdFromContext();

var dashboard = new Dashboard
{
AccountId = accountId,
Name = input.Name?.Trim() ?? string.Empty
Copy link

Copilot AI Nov 24, 2025

Choose a reason for hiding this comment

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

Missing input validation: The CreateDashboard mutation allows empty dashboard names (after trimming whitespace becomes an empty string). According to DashboardService.CreateDashboardAsync, this should return null, but here it proceeds to create the dashboard. Consider validating that the trimmed name is not empty and throw a BAD_USER_INPUT error if it is, consistent with other mutations.

Suggested change
var dashboard = new Dashboard
{
AccountId = accountId,
Name = input.Name?.Trim() ?? string.Empty
var trimmedName = input.Name?.Trim() ?? string.Empty;
if (string.IsNullOrEmpty(trimmedName))
{
throw new GraphQLException(
ErrorBuilder.New()
.SetMessage("Dashboard name cannot be empty.")
.SetCode("BAD_USER_INPUT")
.Build());
}
var dashboard = new Dashboard
{
AccountId = accountId,
Name = trimmedName

Copilot uses AI. Check for mistakes.
};
Comment on lines +40 to +44
Copy link

Copilot AI Nov 24, 2025

Choose a reason for hiding this comment

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

Missing name truncation: The CreateDashboard mutation doesn't enforce the 100-character name length limit that's defined in the database configuration (ConfigureDashboard in BankTrackerDbContext.cs line 115) and enforced in DashboardService.NormalizeName. Consider truncating the name to 100 characters before saving to prevent potential database errors.

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

@copilot open a new pull request to apply changes based on this feedback

check also the security warning from codeQL


await unitOfWork.Dashboards.AddAsync(dashboard);
await unitOfWork.SaveChangesAsync();

return DashboardType.FromDashboard(dashboard);
}
Comment on lines +23 to +50
Copy link

Copilot AI Nov 24, 2025

Choose a reason for hiding this comment

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

Missing [Authorize] attribute: This mutation allows creating dashboards without authentication. Add the [Authorize] attribute to ensure only authenticated users can create dashboards, consistent with the query patterns in DashboardQueries.cs.

Copilot uses AI. Check for mistakes.

public async Task<DashboardType> UpdateDashboard(
[Service] IUnitOfWork unitOfWork,
[Service] IHttpContextAccessor httpContextAccessor,
UpdateDashboardInput input)
{
var accountId = httpContextAccessor.GetAccountIdFromContext();

var dashboard = await unitOfWork.Dashboards.GetByIdAsync(input.Id);
if (dashboard == null || dashboard.AccountId != accountId)
{
throw new GraphQLException(
ErrorBuilder.New()
.SetMessage("Dashboard not found.")
.SetCode("NOT_FOUND")
.Build());
}

if (input.Name != null)
{
dashboard.Name = input.Name.Trim();
Copy link

Copilot AI Nov 24, 2025

Choose a reason for hiding this comment

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

Missing name truncation: The UpdateDashboard mutation doesn't truncate the name to 100 characters like DashboardService.NormalizeName does. Consider truncating the name before saving to maintain consistency with the service layer and prevent potential database errors.

Suggested change
dashboard.Name = input.Name.Trim();
dashboard.Name = input.Name.Trim().Length > 100
? input.Name.Trim().Substring(0, 100)
: input.Name.Trim();

Copilot uses AI. Check for mistakes.
}

Copy link

Copilot AI Nov 24, 2025

Choose a reason for hiding this comment

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

Missing UpdateAsync call: The mutation modifies the dashboard entity but only calls SaveChangesAsync() without calling unitOfWork.Dashboards.UpdateAsync(dashboard). While EF Core change tracking may handle this, it's inconsistent with the pattern used in DashboardService.UpdateDashboardAsync (line 86) and other mutations. Consider calling UpdateAsync for consistency and explicit intent.

Suggested change
await unitOfWork.Dashboards.UpdateAsync(dashboard);

Copilot uses AI. Check for mistakes.
await unitOfWork.SaveChangesAsync();

return DashboardType.FromDashboard(dashboard);
}
Comment on lines +53 to +79
Copy link

Copilot AI Nov 24, 2025

Choose a reason for hiding this comment

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

Missing [Authorize] attribute: This mutation allows updating dashboards without authentication. Add the [Authorize] attribute to ensure only authenticated users can update dashboards, consistent with the query patterns in DashboardQueries.cs.

Copilot uses AI. Check for mistakes.

public async Task<bool> DeleteDashboard(
[Service] IUnitOfWork unitOfWork,
[Service] IHttpContextAccessor httpContextAccessor,
int id)
{
var accountId = httpContextAccessor.GetAccountIdFromContext();

var dashboard = await unitOfWork.Dashboards.GetByIdAsync(id);
if (dashboard == null || dashboard.AccountId != accountId)
{
throw new GraphQLException(
ErrorBuilder.New()
.SetMessage("Dashboard not found.")
.SetCode("NOT_FOUND")
.Build());
}

await unitOfWork.Dashboards.DeleteAsync(id);
await unitOfWork.SaveChangesAsync();

return true;
}
Comment on lines +82 to +103
Copy link

Copilot AI Nov 24, 2025

Choose a reason for hiding this comment

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

Missing [Authorize] attribute: This mutation allows deleting dashboards without authentication. Add the [Authorize] attribute to ensure only authenticated users can delete dashboards, consistent with the query patterns in DashboardQueries.cs.

Copilot uses AI. Check for mistakes.
}
Loading
Loading