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>
|