summary refs log tree commit diff
diff options
context:
space:
mode:
authorRory& <root@rory.gay>2025-06-03 23:38:36 +0200
committerRory& <root@rory.gay>2025-06-03 23:38:50 +0200
commit7ed1b77457f5e41ec5f7ba8e102f13f69380608d (patch)
tree22cbb57a43dfae7cc4458c1e13b3b5ae20595815
parentPrepare for budgeting, move to native createdAt (diff)
downloadnodejs-final-assignment-7ed1b77457f5e41ec5f7ba8e102f13f69380608d.tar.xz
Implement budget handling
-rw-r--r--flake.nix1
-rw-r--r--plan.md2
-rw-r--r--src/api/routes/budgetRoutes.js84
-rw-r--r--testFrontend/SafeNSound.FakeUser/MonitorService.cs7
-rw-r--r--testFrontend/SafeNSound.FakeUser/Program.cs2
-rw-r--r--testFrontend/SafeNSound.FakeUser/RandomAlarmService.cs38
-rw-r--r--testFrontend/SafeNSound.FakeUser/UserService.cs59
-rw-r--r--testFrontend/SafeNSound.Sdk/SafeNSoundClient.cs21
-rw-r--r--testFrontend/SafeNSound.Sdk/WrappedHttpClient.cs4
-rw-r--r--testFrontend/SafeNSound.sln.DotSettings.user1
10 files changed, 134 insertions, 85 deletions
diff --git a/flake.nix b/flake.nix

index e82caa8..16db909 100644 --- a/flake.nix +++ b/flake.nix
@@ -80,6 +80,7 @@ devShell = pkgs.mkShell { buildInputs = with pkgs; [ mongodb-compass + webstorm nodejs nodePackages.prettier ]; diff --git a/plan.md b/plan.md
index 75f4047..9d73806 100644 --- a/plan.md +++ b/plan.md
@@ -25,7 +25,7 @@ - [ ] Organisation (who's coming? announcement of events, ...) - [ ] Budgeting with tracking - [x] Get current budget - - [ ] Add money (monitor) + - [x] Add money (monitor) - [ ] Review spending history - [ ] Request additional budget in case of emergency - [x] Emergency alarm diff --git a/src/api/routes/budgetRoutes.js b/src/api/routes/budgetRoutes.js
index 440c26d..13b4c50 100644 --- a/src/api/routes/budgetRoutes.js +++ b/src/api/routes/budgetRoutes.js
@@ -3,7 +3,7 @@ import { requireUser, requireRole } from '#api/middlewares/index.js'; -import { UserType } from '#db/schemas/index.js'; +import { DbSpendHistory, UserType } from '#db/schemas/index.js'; import { RouteDescription, RouteMethod, @@ -11,6 +11,14 @@ import { } from '#api/RouteDescription.js'; import { getUserById } from '#db/dbAccess/index.js'; import { SafeNSoundError } from '#util/error.js'; +import Joi from 'joi'; + +const budgetModifySchema = new Joi.object({ + venue: Joi.string().required().max(100), + reason: Joi.string().required().max(500), + amount: Joi.number().required().min(0) + // createdAt: Joi.forbidden() +}); /** * @type {RouteDescription} @@ -33,40 +41,32 @@ export const getBudgetByUserRoute = { const user = await getUserById(req.params.id); res.send({ balance: user.balance }); } - }) - } -}; - -/** - * @type {RouteDescription} - */ -export const addBudgetByUserRoute = { - path: '/user/:id/budget/add', - methods: { - get: new RouteMethod({ - description: 'Add budget to a monitored user', + }), + patch: new RouteMethod({ middlewares: [requireMonitor], + description: 'Add budget for a monitored user', async method(req, res) { - if (req.user.type !== UserType.ADMIN) { - if (!req.user.monitoredUsers.includes(req.params.id)) - throw new SafeNSoundError({ - errCode: 'UNAUTHORIZED', - message: - "You do not have permission to add budget to this user's account." - }); - } - - const user = await getUserById(req.params.id); - const amount = parseFloat(req.query.amount); - if (isNaN(amount) || amount <= 0) { + if ( + req.user.type !== UserType.ADMIN && + !req.user.monitoredUsers.includes(req.params.id) + ) throw new SafeNSoundError({ - errCode: 'INVALID_AMOUNT', - message: 'Invalid amount specified.' + errCode: 'UNAUTHORIZED', + message: + "You do not have permission to update this user's budget." }); - } - user.balance += amount; + let data = await budgetModifySchema.validateAsync(req.body); + const user = await getUserById(req.params.id); + user.balance += data.amount; + let histEntry = await DbSpendHistory.create({ + venue: data.venue, + reason: data.reason, + amount: data.amount + }); + user.spendHistory.push(histEntry._id); await user.save(); + res.send({ balance: user.balance }); } }) @@ -84,6 +84,32 @@ export const userBudgetRoute = { async method(req, res) { res.send({ currentBalance: req.user.balance }); } + }), + patch: new RouteMethod({ + middlewares: [requireUser], + description: 'Spend part of budget', + async method(req, res) { + let data = await budgetModifySchema.validateAsync(req.body); + + if (data.amount > req.user.balance) { + throw new SafeNSoundError({ + errCode: 'INSUFFICIENT_FUNDS', + message: + 'You do not have enough funds to complete this transaction.' + }); + } + + req.user.balance -= data.amount; + let histEntry = await DbSpendHistory.create({ + venue: data.venue, + reason: data.reason, + amount: data.amount + }); + req.user.spendHistory.push(histEntry._id); + await req.user.save(); + + res.send({ balance: req.user.balance }); + } }) } }; diff --git a/testFrontend/SafeNSound.FakeUser/MonitorService.cs b/testFrontend/SafeNSound.FakeUser/MonitorService.cs
index b1da7e6..83c9c29 100644 --- a/testFrontend/SafeNSound.FakeUser/MonitorService.cs +++ b/testFrontend/SafeNSound.FakeUser/MonitorService.cs
@@ -94,7 +94,12 @@ public class MonitorService(ILogger<MonitorService> logger, UserStore userStore) } var userId = Random.Shared.GetItems(monitoredUsers, 1).First(); var budget = Random.Shared.NextDouble(); - await user.Client!.AddBudget(userId, budget); + await user.Client!.GetBudget(userId); + await user.Client!.AddBudget(userId, new() { + Amount = budget, + Reason = "Random budget assignment", + Venue = "FakeUser" + }); await Task.Delay(TimeSpan.FromSeconds(1), cancellationToken); } catch (Exception ex) { diff --git a/testFrontend/SafeNSound.FakeUser/Program.cs b/testFrontend/SafeNSound.FakeUser/Program.cs
index 7c3eaff..0852e6f 100644 --- a/testFrontend/SafeNSound.FakeUser/Program.cs +++ b/testFrontend/SafeNSound.FakeUser/Program.cs
@@ -15,7 +15,7 @@ builder.Services.AddSingleton<SafeNSoundConfiguration>(); builder.Services.AddSingleton<SafeNSoundAuthentication>(); builder.Services.AddSingleton<UserStore>(); builder.Services.AddHostedService<UserStore>(sp => sp.GetRequiredService<UserStore>()); -builder.Services.AddHostedService<RandomAlarmService>(); +builder.Services.AddHostedService<UserService>(); builder.Services.AddHostedService<MonitorService>(); // WrappedHttpClient.LogRequests = false; diff --git a/testFrontend/SafeNSound.FakeUser/RandomAlarmService.cs b/testFrontend/SafeNSound.FakeUser/RandomAlarmService.cs deleted file mode 100644
index a2e133f..0000000 --- a/testFrontend/SafeNSound.FakeUser/RandomAlarmService.cs +++ /dev/null
@@ -1,38 +0,0 @@ -namespace SafeNSound.FakeUser; - -public class RandomAlarmService(UserStore userStore) : IHostedService { - private Task? _listenerTask; - private readonly CancellationTokenSource _cts = new(); - - public async Task StartAsync(CancellationToken cancellationToken) { - _listenerTask = Run(_cts.Token); - } - - private static readonly string[] validReasons = ["fall", "toilet"]; - - private async Task Run(CancellationToken cancellationToken) { - while (!cancellationToken.IsCancellationRequested) { - try { - var user = userStore.GetRandomUser(); - var currentAlarm = await user.Client!.GetAlarm(); - if (currentAlarm is null) { - await user.Client!.SetAlarm(new Sdk.AlarmDto { - Reason = Random.Shared.GetItems(validReasons, 1).First() - }); - } - else { - await user.Client!.DeleteAlarm(); - } - } - catch (Exception ex) { - Console.WriteLine($"Error setting/deleting alarm: {ex.Message}"); - } - - await Task.Delay(TimeSpan.FromMilliseconds(250), cancellationToken); - } - } - - public async Task StopAsync(CancellationToken cancellationToken) { - await _cts.CancelAsync(); - } -} \ No newline at end of file diff --git a/testFrontend/SafeNSound.FakeUser/UserService.cs b/testFrontend/SafeNSound.FakeUser/UserService.cs new file mode 100644
index 0000000..e717458 --- /dev/null +++ b/testFrontend/SafeNSound.FakeUser/UserService.cs
@@ -0,0 +1,59 @@ +namespace SafeNSound.FakeUser; + +public class UserService(ILogger<UserService> logger, UserStore userStore) : IHostedService { + private Task _alarmTask, _spendBudgetTask; + private readonly CancellationTokenSource _cts = new(); + + public async Task StartAsync(CancellationToken cancellationToken) { + _alarmTask = ManageAlarms(_cts.Token); + _spendBudgetTask = AssignBudget(_cts.Token); + } + + private static readonly string[] validReasons = ["fall", "toilet"]; + + private async Task ManageAlarms(CancellationToken cancellationToken) { + while (!cancellationToken.IsCancellationRequested) { + try { + var user = userStore.GetRandomUser(); + var currentAlarm = await user.Client!.GetAlarm(); + if (currentAlarm is null) { + await user.Client!.SetAlarm(new Sdk.AlarmDto { + Reason = Random.Shared.GetItems(validReasons, 1).First() + }); + } + else { + await user.Client!.DeleteAlarm(); + } + } + catch (Exception ex) { + logger.LogError(ex, "Error setting/deleting alarm"); + } + + await Task.Delay(TimeSpan.FromMilliseconds(250), cancellationToken); + } + } + + private async Task AssignBudget(CancellationToken cancellationToken) { + while (!cancellationToken.IsCancellationRequested) { + try { + var user = userStore.GetRandomUser(); + var budget = await user.Client!.GetBudget(); + await user.Client!.SpendBudget(new() { + Amount = Math.Min(budget.Amount, Random.Shared.NextDouble()), + Reason = "Random budget spending", + Venue = "The Store" + }); + await Task.Delay(TimeSpan.FromSeconds(1), cancellationToken); + } + catch (Exception ex) { + logger.LogError(ex, "Error spending budget"); + } + } + } + + public async Task StopAsync(CancellationToken cancellationToken) { + await _cts.CancelAsync(); + await _alarmTask; + await _spendBudgetTask; + } +} \ No newline at end of file diff --git a/testFrontend/SafeNSound.Sdk/SafeNSoundClient.cs b/testFrontend/SafeNSound.Sdk/SafeNSoundClient.cs
index 4376d3f..e1564db 100644 --- a/testFrontend/SafeNSound.Sdk/SafeNSoundClient.cs +++ b/testFrontend/SafeNSound.Sdk/SafeNSoundClient.cs
@@ -119,15 +119,20 @@ public class SafeNSoundClient(SafeNSoundConfiguration config, string accessToken res.EnsureSuccessStatusCode(); } - public async Task AddBudget(string userId, BudgetWithReason budget) { + public async Task AddBudget(string userId, BudgetHistoryEntry budget) { var res = await HttpClient.PatchAsJsonAsync($"/user/{userId}/budget", budget); res.EnsureSuccessStatusCode(); } + + public async Task SpendBudget(BudgetHistoryEntry budget) { + var res = await HttpClient.PatchAsJsonAsync($"/budget/@me", budget); + res.EnsureSuccessStatusCode(); + } public async Task<BudgetWithHistory> GetBudget(string userId = "@me") { var res = await HttpClient.GetAsync( userId == "@me" - ? $"/budget" + ? $"/budget/@me" : $"/user/{userId}/budget" ); res.EnsureSuccessStatusCode(); @@ -157,16 +162,6 @@ public class DeviceDto { public DateTime LastSeen { get; set; } } -public class Budget { - [JsonPropertyName("budget")] - public double Amount { get; set; } -} - -public class BudgetWithReason : Budget { - [JsonPropertyName("reason")] - public string? Reason { get; set; } -} - public class BudgetWithHistory { [JsonPropertyName("budget")] public double Amount { get; set; } @@ -186,5 +181,5 @@ public class BudgetHistoryEntry { public string Reason { get; set; } [JsonPropertyName("createdAt")] - public DateTime CreatedAt { get; set; } + public DateTime? CreatedAt { get; set; } } \ No newline at end of file diff --git a/testFrontend/SafeNSound.Sdk/WrappedHttpClient.cs b/testFrontend/SafeNSound.Sdk/WrappedHttpClient.cs
index aa785cd..49bd212 100644 --- a/testFrontend/SafeNSound.Sdk/WrappedHttpClient.cs +++ b/testFrontend/SafeNSound.Sdk/WrappedHttpClient.cs
@@ -337,7 +337,7 @@ public class WrappedHttpClient { var request = new HttpRequestMessage(HttpMethod.Delete, url) { - Content = new StringContent(JsonSerializer.Serialize(payload), Encoding.UTF8, "application/json") + Content = new StringContent(JsonSerializer.Serialize(payload, GetJsonSerializerOptions()), Encoding.UTF8, "application/json") }; return await SendAsync(request); } @@ -345,7 +345,7 @@ public class WrappedHttpClient public async Task<HttpResponseMessage> PatchAsJsonAsync<T>(string url, T payload) { var request = new HttpRequestMessage(HttpMethod.Patch, url) { - Content = new StringContent(JsonSerializer.Serialize(payload), Encoding.UTF8, "application/json") + Content = new StringContent(JsonSerializer.Serialize(payload, GetJsonSerializerOptions()), Encoding.UTF8, "application/json") }; return await SendAsync(request); } diff --git a/testFrontend/SafeNSound.sln.DotSettings.user b/testFrontend/SafeNSound.sln.DotSettings.user
index 43e3164..1235bc1 100644 --- a/testFrontend/SafeNSound.sln.DotSettings.user +++ b/testFrontend/SafeNSound.sln.DotSettings.user
@@ -1,6 +1,7 @@ <wpf:ResourceDictionary xml:space="preserve" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:s="clr-namespace:System;assembly=mscorlib" xmlns:ss="urn:shemas-jetbrains-com:settings-storage-xaml" xmlns:wpf="http://schemas.microsoft.com/winfx/2006/xaml/presentation"> <s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AHttpResponseMessage_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003F_002Econfig_003FJetBrains_003FRider2024_002E3_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003Fc04aec05cdf74c1a9565aec2103f20961a1e00_003F43_003Ff3c3d460_003FHttpResponseMessage_002Ecs/@EntryIndexedValue">ForceIncluded</s:String> <s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003ARandom_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003F_002Econfig_003FJetBrains_003FRider2024_002E3_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003Fba81a6af46624b56ac6210bdbcc99af2d19e00_003F48_003Fde29dea3_003FRandom_002Ecs/@EntryIndexedValue">ForceIncluded</s:String> + <s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AStackFrameIterator_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003F_002Econfig_003FJetBrains_003FRider2024_002E3_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003Fba81a6af46624b56ac6210bdbcc99af2d19e00_003Fe4_003Fa18c424f_003FStackFrameIterator_002Ecs/@EntryIndexedValue">ForceIncluded</s:String> <s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003ATaskAwaiter_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003F_002Econfig_003FJetBrains_003FRider2024_002E3_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003Fba81a6af46624b56ac6210bdbcc99af2d19e00_003F4b_003Fb9d0e80e_003FTaskAwaiter_002Ecs/@EntryIndexedValue">ForceIncluded</s:String> <s:Boolean x:Key="/Default/Monitoring/Counters/=System_002ERuntime/@EntryIndexedValue">True</s:Boolean> <s:Boolean x:Key="/Default/Monitoring/Counters/=Microsoft_002EAspNetCore_002EHttp_002EConnections/@EntryIndexedValue">True</s:Boolean>