diff --git a/.idea/dataSources.xml b/.idea/dataSources.xml
index f495692..4dc945a 100644
--- a/.idea/dataSources.xml
+++ b/.idea/dataSources.xml
@@ -5,7 +5,7 @@
<driver-ref>mongo.4</driver-ref>
<synchronize>true</synchronize>
<jdbc-driver>com.dbschema.MongoJdbcDriver</jdbc-driver>
- <jdbc-url>mongodb://localhost:27017/nodejs</jdbc-url>
+ <jdbc-url>mongodb://localhost:27017/nodejs?authSource=admin</jdbc-url>
<working-dir>$ProjectFileDir$</working-dir>
<driver-properties>
<property name="authSource" value="admin" />
diff --git a/.idea/nodejs-final-assignment.iml b/.idea/nodejs-final-assignment.iml
index 24643cc..d1e5227 100644
--- a/.idea/nodejs-final-assignment.iml
+++ b/.idea/nodejs-final-assignment.iml
@@ -5,6 +5,7 @@
<excludeFolder url="file://$MODULE_DIR$/.tmp" />
<excludeFolder url="file://$MODULE_DIR$/temp" />
<excludeFolder url="file://$MODULE_DIR$/tmp" />
+ <excludeFolder url="file://$MODULE_DIR$/result" />
</content>
<orderEntry type="inheritedJdk" />
<orderEntry type="sourceFolder" forTests="false" />
diff --git a/DEPLOY.md b/DEPLOY.md
new file mode 100644
index 0000000..a427ed7
--- /dev/null
+++ b/DEPLOY.md
@@ -0,0 +1,51 @@
+# Deployment
+---
+
+## NixOS (recommended)
+
+flake.nix: (production: [inputs](https://cgit.rory.gay/Rory-Open-Architecture.git/tree/flake.nix?h=d94f5#n104), [modules](https://cgit.rory.gay/Rory-Open-Architecture.git/tree/flake.nix?h=d94f5#n147), [service](https://cgit.rory.gay/Rory-Open-Architecture.git/tree/host/Rory-ovh/services/safensound.nix))
+```nix
+# inputs section - using public mirror for unauthenticated access
+inputs.safeNSound.url = "git+https://cgit.rory.gay/school/nodejs-final-assignment.git/";
+
+# system configuration section
+# modules = [
+safeNSound.modules.default
+
+# configuration section
+# Make sure mongodb is set up: services.mongodb = { enable = true; ... };
+services.safeNSound = {
+ enable = true;
+ package = safeNSound.packages.default;
+ port = 3000; # or any other port you prefer
+ logRequests = "-"; # or "true" to log requests
+ databaseSecretPath = "/path/to/mongodb/connection/string";
+ jwtSecretPath = "/path/to/jwt/secret/certificate";
+ logQueries = false; # set to true to enable mongoose debug logs
+ logAuth = false; # set to true to enable authentication debug logs
+};
+```
+
+## Other Linux distros:
+
+### Preparation
+
+Make sure you have the following installed:
+- Node.js (v22 or later)
+- MongoDB
+
+```shell
+git clone --recursive https://cgit.rory.gay/school/nodejs-final-assignment.git/
+cd nodejs-final-assignment
+npm i
+```
+
+### Installation
+
+Please see your service manager:
+- systemd (Debian, Arch, Fedora, ...): /etc/systemd/system/SafeNSound.service (see [example](https://cgit.rory.gay/school/nodejs-final-assignment.git/tree/doc/systemd.service))
+- Other: please refer to your service manager's documentation.
+
+## General setup considerations:
+- Do not run as root!
+- Secrets live on disk and should be handled via whatever secret scheme your system allows. (NixOS uses systemd by default).
\ No newline at end of file
diff --git a/README.md b/README.md
index fa2e942..bca91cb 100644
--- a/README.md
+++ b/README.md
@@ -13,8 +13,8 @@ Environment variables:
| Name | Default | Description |
| ---- | ------- | ----------- |
| `PORT` | `3000` | The port the server will run on. |
-| `LOG_REQUESTS` | `-` | Requests to log to the console by status, `-` to invert. |
| `DATABASE_SECRET_PATH` | ` ` | The path to the mongodb connection string. |
| `JWT_SECRET_PATH` | ` ` | The path to the JWT secret certificate. |
+| `LOG_REQUESTS` | `-` | Requests to log to the console by status, `-` to invert. |
| `LOG_QUERIES` | `false` | Whether to enable mongoose debug logs |
| `LOG_AUTH` | `false` | Whether to enable authentication debug logs |
diff --git a/doc/systemd.service b/doc/systemd.service
new file mode 100644
index 0000000..37a98ca
--- /dev/null
+++ b/doc/systemd.service
@@ -0,0 +1,30 @@
+[Unit]
+After=network.target mongodb.service
+Description=SafeNSound Service
+Requires=mongodb.service
+
+[Service]
+Environment="DATABASE_SECRET_PATH=/run/credentials/safensound.service/mongodb"
+Environment="JWT_SECRET_PATH=/var/lib/SafeNSound"
+Environment="LOG_AUTH=true"
+Environment="LOG_QUERIES=true"
+Environment="LOG_REQUESTS=-"
+Environment="PORT=7645"
+DynamicUser=true
+ExecStart=/usr/bin/env node /opt/SafeNSound
+LoadCredential=mongodb:/data/secrets/safensound-mongodb
+NoNewPrivileges=true
+PrivateDevices=true
+PrivateTmp=true
+ProtectHome=true
+ProtectSystem=strict
+Restart=always
+StartLimitBurst=600
+StartLimitIntervalSec=60
+StateDirectory=SafeNSound
+StateDirectoryMode=0700
+Type=simple
+WorkingDirectory=/var/lib/SafeNSound
+
+[Install]
+WantedBy=multi-user.target
diff --git a/endpoints.http b/endpoints.http
index aab693a..ce4c2d3 100644
--- a/endpoints.http
+++ b/endpoints.http
@@ -3,40 +3,74 @@
@password=admin
@email=admin@example.com
@userType=admin
+@accessToken=someValueXyzXyz
-GET {{baseUrl}}/budget/:id/add HTTP/1.1
-
-###
-# Get all users (raw)
-GET {{baseUrl}}/admin/users HTTP/1.1
+# Get all user IDs
+GET {{baseUrl}}/admin/allUserIds HTTP/1.1
Authorization: Bearer {{accessToken}}
###
-# Get a user (raw)
-GET {{baseUrl}}/admin/user/:id HTTP/1.1
+# Monitor all users
+POST {{baseUrl}}/admin/monitorAllUsers HTTP/1.1
Authorization: Bearer {{accessToken}}
###
-DELETE {{baseUrl}}/admin/user/:id HTTP/1.1
-
-###
-GET {{baseUrl}}/alarm/:id HTTP/1.1
+# Get the alarm for a monitored user, if one is set
+GET {{baseUrl}}/user/:id/alarm HTTP/1.1
+Authorization: Bearer {{accessToken}}
###
-PUT {{baseUrl}}/alarm/:id HTTP/1.1
+# Clear the alarm for a monitored user
+DELETE {{baseUrl}}/user/:id/alarm HTTP/1.1
+Authorization: Bearer {{accessToken}}
###
+# Get a list of all alarms for monitored users
GET {{baseUrl}}/alarms HTTP/1.1
+Authorization: Bearer {{accessToken}}
###
+# Get the current user's alarm
GET {{baseUrl}}/alarm/@me HTTP/1.1
+Authorization: Bearer {{accessToken}}
###
+# Raise an alarm (enum: one of "fall" or "toilet")
PUT {{baseUrl}}/alarm/@me HTTP/1.1
+Authorization: Bearer {{accessToken}}
+Content-Type: application/json
+
+{
+ "reason": "fall"
+}
+
###
+# Clear alarm
DELETE {{baseUrl}}/alarm/@me HTTP/1.1
+Authorization: Bearer {{accessToken}}
+
+###
+# Get assigned users
+GET {{baseUrl}}/monitor/assignedUsers HTTP/1.1
+Authorization: Bearer {{accessToken}}
+
+###
+# Add an assigned user by ID
+PATCH {{baseUrl}}/monitor/assignedUsers HTTP/1.1
+Authorization: Bearer {{accessToken}}
+Content-Type: application/json
+
+{
+ "userId": "abcdef"
+}
+
+
+###
+# Remove an assigned user by ID
+DELETE {{baseUrl}}/monitor/assignedUsers HTTP/1.1
+Authorization: Bearer {{accessToken}}
###
# Delete account
@@ -51,13 +85,27 @@ Content-Type: application/json
###
-GET {{baseUrl}}/budget/:id HTTP/1.1
+# Get the budget for a monitored user
+GET {{baseUrl}}/user/:id/budget HTTP/1.1
+Authorization: Bearer {{accessToken}}
###
-GET {{baseUrl}}/budget/@me HTTP/1.1
+# Add budget for a monitored user
+PATCH {{baseUrl}}/user/:id/budget HTTP/1.1
+Authorization: Bearer {{accessToken}}
+Content-Type: application/json
+
+{
+ "venue": "Monitor 123",
+ "reason": "Just short for a coke to deal with diabetes",
+ "amount": 0.15
+}
+
###
+# Get all devices registered to the user
GET {{baseUrl}}/auth/devices HTTP/1.1
+Authorization: Bearer {{accessToken}}
###
# Get the index page (empty)
@@ -76,11 +124,32 @@ Content-Type: application/json
###
-# Log out from a device (TODO)
+# Log out from a device
POST {{baseUrl}}/auth/logout HTTP/1.1
Authorization: Bearer {{accessToken}}
###
+# Get user device by ID
+GET {{baseUrl}}/auth/devices/:id HTTP/1.1
+Authorization: Bearer {{accessToken}}
+
+###
+# Delete user device by ID
+DELETE {{baseUrl}}/auth/devices/:id HTTP/1.1
+Authorization: Bearer {{accessToken}}
+
+###
+# Update user device by ID
+PATCH {{baseUrl}}/auth/devices/:id HTTP/1.1
+Authorization: Bearer {{accessToken}}
+Content-Type: application/json
+
+{
+ "name": "New Device Name"
+}
+
+
+###
# Create a new user
POST {{baseUrl}}/auth/register HTTP/1.1
Content-Type: application/json
@@ -98,7 +167,25 @@ Content-Type: application/json
GET {{baseUrl}}/status HTTP/1.1
###
+GET {{baseUrl}}/budget/@me HTTP/1.1
+Authorization: Bearer {{accessToken}}
+
+###
+# Spend part of budget
+PATCH {{baseUrl}}/budget/@me HTTP/1.1
+Authorization: Bearer {{accessToken}}
+Content-Type: application/json
+
+{
+ "venue": "The Store",
+ "reason": "Bought a coke",
+ "amount": 0.85
+}
+
+
+###
# Get current user
GET {{baseUrl}}/auth/whoami HTTP/1.1
+Authorization: Bearer {{accessToken}}
###
diff --git a/flake.nix b/flake.nix
index 211b64b..16db909 100644
--- a/flake.nix
+++ b/flake.nix
@@ -80,64 +80,96 @@
devShell = pkgs.mkShell {
buildInputs = with pkgs; [
mongodb-compass
+ webstorm
nodejs
nodePackages.prettier
];
};
}
- )) // {
- nixosModules.default = { pkgs, config, lib, ...}: {
- options.services.safensound = {
- enable = lib.mkEnableOption "Enable SafeNSound service";
- package = lib.mkOption {
- type = lib.types.package;
- default = self.packages.${pkgs.stdenv.hostPlatform.system}.default;
- description = "The SafeNSound service package to run.";
- };
- port = lib.mkOption {
- type = lib.types.port;
- default = 3000;
- description = "The port on which the SafeNSound service will listen.";
- };
- dbCredentialsPath = lib.mkOption {
- type = lib.types.path;
- description = "Path to the database credentials file.";
- };
- jwtSecretPath = lib.mkOption {
- type = lib.types.path;
- description = "Path to the JWT secret directory.";
- };
- logRequests = lib.mkEnableOption "Log requests";
- logQueries = lib.mkEnableOption "Log queries";
- logAuth = lib.mkEnableOption "Log authentication";
- };
-
- config = lib.mkIf (config.services.safensound.enable) (
- let
- cfg = config.services.safensound;
- in
+ ))
+ // {
+ nixosModules.default =
+ {
+ pkgs,
+ config,
+ lib,
+ ...
+ }:
{
- systemd.services.safensound = {
- description = "SafeNSound Service";
- wantedBy = [ "multi-user.target" ];
- after = [ "network.target" "mongodb.service" ];
- requires = [ "mongodb.service" ];
- environment = {
- PORT = cfg.port;
- LOG_REQUESTS = cfg.logRequests;
- DATABASE_SECRET_PATH = cfg.dbCredentialsPath;
- JWT_SECRET_PATH = cfg.jwtSecretPath;
- LOG_QUERIES = cfg.logQueries;
- LOG_AUTH = cfg.logAuth;
+ options.services.safensound = {
+ enable = lib.mkEnableOption "Enable SafeNSound service";
+ package = lib.mkOption {
+ type = lib.types.package;
+ default = self.packages.${pkgs.stdenv.hostPlatform.system}.default;
+ description = "The SafeNSound service package to run.";
};
- serviceConfig = {
- Type = "simple";
- ExecStart = "${cfg.package}/bin/start";
- Restart = "on-failure";
- DynamicUser = true;
+ port = lib.mkOption {
+ type = lib.types.port;
+ default = 3000;
+ description = "The port on which the SafeNSound service will listen.";
};
+ dbCredentialsPath = lib.mkOption {
+ type = lib.types.path;
+ description = "Path to the database credentials file.";
+ };
+ jwtSecretPath = lib.mkOption {
+ type = lib.types.path;
+ default = "/var/lib/SafeNSound";
+ description = "Path to the JWT secret directory.";
+ };
+ logRequests = lib.mkOption {
+ type = lib.types.string;
+ description = "Which requests to log.";
+ default = "-";
+ };
+ logQueries = lib.mkEnableOption "Log queries";
+ logAuth = lib.mkEnableOption "Log authentication";
};
- });
- };
+
+ config = lib.mkIf (config.services.safensound.enable) (
+ let
+ cfg = config.services.safensound;
+ in
+ {
+ systemd.services.safensound = {
+ description = "SafeNSound Service";
+ wantedBy = [ "multi-user.target" ];
+ after = [
+ "network.target"
+ "mongodb.service"
+ ];
+ requires = [ "mongodb.service" ];
+ environment = {
+ PORT = toString cfg.port;
+ DATABASE_SECRET_PATH = "/run/credentials/safensound.service/mongodb";
+ JWT_SECRET_PATH = cfg.jwtSecretPath;
+ LOG_AUTH = lib.boolToString cfg.logAuth;
+ LOG_QUERIES = lib.boolToString cfg.logQueries;
+ LOG_REQUESTS = cfg.logRequests;
+ };
+ serviceConfig = {
+ Type = "simple";
+ ExecStart = "${cfg.package}/bin/start";
+ WorkingDirectory = "/var/lib/SafeNSound";
+ StateDirectory = "SafeNSound";
+ StateDirectoryMode = "0700";
+ ProtectSystem = "strict";
+ ProtectHome = true;
+ PrivateTmp = true;
+ NoNewPrivileges = true;
+ PrivateDevices = true;
+ DynamicUser = true;
+ Restart = "always";
+ StartLimitIntervalSec = 60;
+ StartLimitBurst = 5;
+
+ LoadCredential = [
+ "mongodb:${cfg.dbCredentialsPath}"
+ ];
+ };
+ };
+ }
+ );
+ };
};
-}
\ No newline at end of file
+}
diff --git a/plan.md b/plan.md
index 3588bf8..9d73806 100644
--- a/plan.md
+++ b/plan.md
@@ -7,7 +7,7 @@
- [x] MongoDB CRUD operations and Mongoose modeling (with Joi validation)
- [ ] Complete data and input validation (including ObjectId validation)
- [x] Middleware functions
-- [ ] A detailed deployment step-by-step plan: the application must be deployed on a cloud server and publicly accessible
+- [x] A detailed deployment step-by-step plan: the application must be deployed on a cloud server and publicly accessible
- [x] REST Client API calls provided for all API endpoints
- [x] Comprehensive API documentation
@@ -17,14 +17,15 @@
- [x] Registration
- [ ] Validation based on type
- [x] Login
- - [-] Logout
+ - [x] Logout
- [x] Delete
- [ ] Password reset
- [ ] User profile management
- - [ ] Device management
+ - [x] Device management
- [ ] Organisation (who's coming? announcement of events, ...)
- [ ] Budgeting with tracking
- [x] Get current budget
+ - [x] Add money (monitor)
- [ ] Review spending history
- [ ] Request additional budget in case of emergency
- [x] Emergency alarm
diff --git a/src/api/routes.js b/src/api/routes.js
index 279b1ef..e2765a3 100644
--- a/src/api/routes.js
+++ b/src/api/routes.js
@@ -15,6 +15,7 @@ function logHttpHeader() {
logPrefixed('@password=admin');
logPrefixed('@email=admin@example.com');
logPrefixed('@userType=admin');
+ logPrefixed('@accessToken=someValueXyzXyz');
logPrefixed('');
logPrefixed('');
}
diff --git a/src/api/routes/adminRoutes.js b/src/api/routes/adminRoutes.js
index 7a1d1e2..4baa5e4 100644
--- a/src/api/routes/adminRoutes.js
+++ b/src/api/routes/adminRoutes.js
@@ -16,6 +16,9 @@ export const adminGetUserIdsRoute = {
path: '/admin/allUserIds',
methods: {
get: new RouteMethod({
+ exampleHeaders: {
+ Authorization: 'Bearer {{accessToken}}'
+ },
middlewares: [requireAdmin],
description: 'Get all user IDs',
async method(req, res) {
@@ -23,9 +26,13 @@ export const adminGetUserIdsRoute = {
res.status(200);
res.write('[\n');
+ let first = true;
const users = DbUser.find().lean().cursor();
for await (const user of users) {
- res.write(JSON.stringify(user._id) + ',\n');
+ res.write(
+ (first ? '' : ',') + JSON.stringify(user._id) + '\n'
+ );
+ first = false;
}
res.write(']');
@@ -38,6 +45,9 @@ export const adminMonitorAllRoute = {
path: '/admin/monitorAllUsers',
methods: {
post: new RouteMethod({
+ exampleHeaders: {
+ Authorization: 'Bearer {{accessToken}}'
+ },
middlewares: [requireAdmin],
description: 'Monitor all users',
async method(req, res) {
diff --git a/src/api/routes/alarmRoutes.js b/src/api/routes/alarmRoutes.js
index aeae4ab..b0147ab 100644
--- a/src/api/routes/alarmRoutes.js
+++ b/src/api/routes/alarmRoutes.js
@@ -15,14 +15,20 @@ export const alarmByUserRoute = {
path: '/user/:id/alarm',
methods: {
get: new RouteMethod({
+ exampleHeaders: {
+ Authorization: 'Bearer {{accessToken}}'
+ },
middlewares: [requireMonitor],
description: 'Get the alarm for a monitored user, if one is set',
async method(req, res) {
const user = await getUserById(req.params.id);
- res.send(user.alarm);
+ res.send(user.alarm ?? 'null');
}
}),
delete: new RouteMethod({
+ exampleHeaders: {
+ Authorization: 'Bearer {{accessToken}}'
+ },
middlewares: [requireMonitor],
description: 'Clear the alarm for a monitored user',
async method(req, res) {
@@ -44,23 +50,27 @@ export const alarmListRoute = {
path: '/alarms',
methods: {
get: new RouteMethod({
+ exampleHeaders: {
+ Authorization: 'Bearer {{accessToken}}'
+ },
middlewares: [requireMonitor],
description: 'Get a list of all alarms for monitored users',
async method(req, res) {
// execute the query asynchronously and manually construct a response, for scaling reasons
const users = DbUser.find({
- _id: { $in: req.user.monitoredUsers }
+ _id: { $in: req.user.monitoredUsers },
+ alarm: { $exists: true, $ne: null }
})
.lean()
.cursor();
res.status(200);
res.write('{\n');
+ let first = true;
for await (const user of users) {
- if (user.alarm) {
- res.write(
- `"${user._id}": ${JSON.stringify(user.alarm)},\n`
- );
- }
+ res.write(
+ `${first ? '' : ','}"${user._id}": ${JSON.stringify(user.alarm)}\n`
+ );
+ first = false;
}
res.write('}');
res.end();
@@ -76,15 +86,24 @@ export const alarmRoute = {
path: '/alarm/@me',
methods: {
get: new RouteMethod({
+ exampleHeaders: {
+ Authorization: 'Bearer {{accessToken}}'
+ },
middlewares: [requireUser],
description: "Get the current user's alarm",
async method(req, res) {
- res.send(req.user.alarm);
+ res.send(req.user.alarm ?? 'null');
}
}),
put: new RouteMethod({
+ exampleHeaders: {
+ Authorization: 'Bearer {{accessToken}}'
+ },
+ exampleBody: {
+ reason: 'fall'
+ },
middlewares: [requireUser],
- description: 'Raise an alarm',
+ description: 'Raise an alarm (enum: one of "fall" or "toilet")',
async method(req, res) {
req.user.alarm = await AlarmDto.create(req.body);
await req.user.save();
@@ -92,6 +111,9 @@ export const alarmRoute = {
}
}),
delete: new RouteMethod({
+ exampleHeaders: {
+ Authorization: 'Bearer {{accessToken}}'
+ },
middlewares: [requireUser],
description: 'Clear alarm',
async method(req, res) {
diff --git a/src/api/routes/assignedUserRoutes.js b/src/api/routes/assignedUserRoutes.js
index dac9b13..e870cd8 100644
--- a/src/api/routes/assignedUserRoutes.js
+++ b/src/api/routes/assignedUserRoutes.js
@@ -10,6 +10,9 @@ export const assignedUserRoute = {
path: '/monitor/assignedUsers',
methods: {
get: new RouteMethod({
+ exampleHeaders: {
+ Authorization: 'Bearer {{accessToken}}'
+ },
middlewares: [requireMonitor],
description: 'Get assigned users',
async method(req, res) {
@@ -17,7 +20,13 @@ export const assignedUserRoute = {
}
}),
patch: {
+ exampleHeaders: {
+ Authorization: 'Bearer {{accessToken}}'
+ },
middlewares: [requireMonitor],
+ exampleBody: {
+ userId: 'abcdef'
+ },
description: 'Add an assigned user by ID',
async method(req, res) {
if (!req.body.userId) {
@@ -41,6 +50,9 @@ export const assignedUserRoute = {
}
},
delete: {
+ exampleHeaders: {
+ Authorization: 'Bearer {{accessToken}}'
+ },
middlewares: [requireMonitor],
description: 'Remove an assigned user by ID',
async method(req, res) {
diff --git a/src/api/routes/auth/accountRoutes.js b/src/api/routes/auth/accountRoutes.js
index 34592ed..f0d0102 100644
--- a/src/api/routes/auth/accountRoutes.js
+++ b/src/api/routes/auth/accountRoutes.js
@@ -3,6 +3,7 @@ import { AuthDto, RegisterDto } from '#dto/index.js';
import { RouteDescription, RouteMethod } from '#api/RouteDescription.js';
import { WhoAmIDto } from '#dto/auth/WhoAmIDto.js';
import { requireAuth } from '#api/middlewares/index.js';
+import { SafeNSoundError } from '#util/error.js';
/**
* @type {RouteDescription}
@@ -59,13 +60,17 @@ export const logoutRoute = {
path: '/auth/logout',
methods: {
post: new RouteMethod({
- description: 'Log out from a device (TODO)',
+ description: 'Log out from a device',
exampleHeaders: {
Authorization: 'Bearer {{accessToken}}'
},
+ middlewares: [requireAuth],
async method(req, res) {
- const data = await AuthDto.create(req.body);
- // const loginResult = await deleteDevice(data, );
+ const deviceIndex = req.user.devices.findIndex(
+ device => device.id === req.device._id
+ );
+ req.user.devices.splice(deviceIndex, 1);
+ await req.user.save();
res.status(204).send();
}
})
@@ -102,6 +107,9 @@ export const whoAmI = {
methods: {
get: new RouteMethod({
description: 'Get current user',
+ exampleHeaders: {
+ Authorization: 'Bearer {{accessToken}}'
+ },
middlewares: [requireAuth],
async method(req, res) {
const data = await WhoAmIDto.create({
diff --git a/src/api/routes/auth/adminAccountRoutes.js b/src/api/routes/auth/adminAccountRoutes.js
deleted file mode 100644
index b485002..0000000
--- a/src/api/routes/auth/adminAccountRoutes.js
+++ /dev/null
@@ -1,58 +0,0 @@
-import {
- DbUser,
- deleteUser,
- getUserById,
- loginUser,
- registerUser,
- UserType
-} from '#db/index.js';
-import { AuthDto, RegisterDto } from '#dto/index.js';
-import { requireAdmin, requireRole } from '#api/middlewares/index.js';
-import { RouteDescription, RouteMethod } from '#api/RouteDescription.js';
-
-/**
- * @type {RouteDescription}
- */
-export const adminGetUsersRoute = {
- path: '/admin/users',
- methods: {
- get: new RouteMethod({
- description: 'Get all users (raw)',
- exampleHeaders: {
- Authorization: 'Bearer {{accessToken}}'
- },
- middlewares: [requireAdmin],
- async method(req, res) {
- res.send(DbUser.find({}).exec());
- }
- })
- }
-};
-
-/**
- * @type {RouteDescription}
- */
-export const adminUserRoute = {
- path: '/admin/user/:id',
- methods: {
- get: new RouteMethod({
- description: 'Get a user (raw)',
- exampleHeaders: {
- Authorization: 'Bearer {{accessToken}}'
- },
- middlewares: [requireAdmin],
- async method(req, res) {
- const user = await getUserById(req.params.id);
- res.send(user);
- }
- }),
- delete: new RouteMethod({
- middlewares: [requireAdmin],
- description: 'Delete a user',
- async method(req, res) {
- await deleteUser(data);
- res.status(204).send();
- }
- })
- }
-};
diff --git a/src/api/routes/auth/deviceRoutes.js b/src/api/routes/auth/deviceRoutes.js
index 551252b..41802b8 100644
--- a/src/api/routes/auth/deviceRoutes.js
+++ b/src/api/routes/auth/deviceRoutes.js
@@ -1,7 +1,11 @@
-import { registerUser } from '#db/index.js';
-import { RegisterDto } from '#dto/index.js';
import { requireAuth } from '#api/middlewares/index.js';
import { RouteMethod } from '#api/RouteDescription.js';
+import { SafeNSoundError } from '#util/error.js';
+import Joi from 'joi';
+
+const deviceUpdateSchema = Joi.object({
+ name: Joi.string().optional().max(100)
+});
/**
* @type {RouteDescription}
@@ -10,12 +14,97 @@ export const getDevicesRoute = {
path: '/auth/devices',
methods: {
get: new RouteMethod({
+ exampleHeaders: {
+ Authorization: 'Bearer {{accessToken}}'
+ },
middlewares: [requireAuth],
description: 'Get all devices registered to the user',
async method(req, res) {
- const data = await RegisterDto.create(req.body);
- const registerResult = await registerUser(data);
- res.send(registerResult);
+ res.send(req.user.devices);
+ }
+ })
+ }
+};
+
+/**
+ * @type {RouteDescription}
+ */
+export const manageDeviceRoute = {
+ path: '/auth/devices/:id',
+ methods: {
+ get: new RouteMethod({
+ exampleHeaders: {
+ Authorization: 'Bearer {{accessToken}}'
+ },
+ middlewares: [requireAuth],
+ description: 'Get user device by ID',
+ async method(req, res) {
+ const device = req.user.devices.find(
+ device => device.id === req.params.id
+ );
+ if (!device) {
+ res.status(404).send(
+ new SafeNSoundError({
+ errCode: 'ENTITY_NOT_FOUND',
+ message: 'Device not found'
+ })
+ );
+ return;
+ }
+ res.send(device);
+ }
+ }),
+ delete: new RouteMethod({
+ exampleHeaders: {
+ Authorization: 'Bearer {{accessToken}}'
+ },
+ middlewares: [requireAuth],
+ description: 'Delete user device by ID',
+ async method(req, res) {
+ const deviceIndex = req.user.devices.findIndex(
+ device => device.id === req.params.id
+ );
+ if (deviceIndex === -1) {
+ res.status(404).send(
+ new SafeNSoundError({
+ errCode: 'ENTITY_NOT_FOUND',
+ message: 'Device not found'
+ })
+ );
+ return;
+ }
+ req.user.devices.splice(deviceIndex, 1);
+ await req.user.save();
+ res.status(204).send();
+ }
+ }),
+ patch: new RouteMethod({
+ exampleHeaders: {
+ Authorization: 'Bearer {{accessToken}}'
+ },
+ exampleBody: {
+ name: 'New Device Name'
+ },
+ middlewares: [requireAuth],
+ description: 'Update user device by ID',
+ async method(req, res) {
+ const device = req.user.devices.find(
+ device => device.id === req.params.id
+ );
+ if (!device) {
+ res.status(404).send(
+ new SafeNSoundError({
+ errCode: 'ENTITY_NOT_FOUND',
+ message: 'Device not found'
+ })
+ );
+ return;
+ }
+ if (req.body.name) {
+ device.name = req.body.name;
+ }
+ await req.user.save();
+ res.send(device);
}
})
}
diff --git a/src/api/routes/auth/index.js b/src/api/routes/auth/index.js
index 2d2cc86..e687911 100644
--- a/src/api/routes/auth/index.js
+++ b/src/api/routes/auth/index.js
@@ -1,3 +1,2 @@
export * from './accountRoutes.js';
export * from './deviceRoutes.js';
-export * from './adminAccountRoutes.js';
diff --git a/src/api/routes/budgetRoutes.js b/src/api/routes/budgetRoutes.js
index bcb9711..e522442 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,14 +11,25 @@ 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}
*/
export const getBudgetByUserRoute = {
- path: '/budget/:id',
+ path: '/user/:id/budget',
methods: {
get: new RouteMethod({
+ exampleHeaders: {
+ Authorization: 'Bearer {{accessToken}}'
+ },
middlewares: [requireMonitor],
description: 'Get the budget for a monitored user',
async method(req, res) {
@@ -33,40 +44,40 @@ export const getBudgetByUserRoute = {
const user = await getUserById(req.params.id);
res.send({ balance: user.balance });
}
- })
- }
-};
-
-/**
- * @type {RouteDescription}
- */
-export const addBudgetByUserRoute = {
- path: '/budget/:id/add',
- methods: {
- get: new RouteMethod({
- description: 'Add budget to a monitored user',
+ }),
+ patch: new RouteMethod({
+ exampleHeaders: {
+ Authorization: 'Bearer {{accessToken}}'
+ },
+ exampleBody: {
+ venue: 'Monitor 123',
+ reason: 'Just short for a coke to deal with diabetes',
+ amount: 0.15
+ },
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 });
}
})
@@ -76,14 +87,51 @@ export const addBudgetByUserRoute = {
/**
* @type {RouteDescription}
*/
-export const getBudgetRoute = {
+export const userBudgetRoute = {
path: '/budget/@me',
methods: {
get: new RouteMethod({
+ exampleHeaders: {
+ Authorization: 'Bearer {{accessToken}}'
+ },
middlewares: [requireUser],
async method(req, res) {
res.send({ currentBalance: req.user.balance });
}
+ }),
+ patch: new RouteMethod({
+ exampleHeaders: {
+ Authorization: 'Bearer {{accessToken}}'
+ },
+ exampleBody: {
+ venue: 'The Store',
+ reason: 'Bought a coke',
+ amount: 0.85
+ },
+ 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/src/db/schemas/sensorHistory.js b/src/db/schemas/sensorHistory.js
index 152f03a..e016079 100644
--- a/src/db/schemas/sensorHistory.js
+++ b/src/db/schemas/sensorHistory.js
@@ -6,11 +6,6 @@ import { model, Schema, ObjectId } from 'mongoose';
*/
export const sensorHistorySchema = new Schema(
{
- createdAt: {
- type: Date,
- default: Date.now,
- immutable: true
- },
sensor: {
type: String,
required: true,
@@ -23,6 +18,10 @@ export const sensorHistorySchema = new Schema(
}
},
{
+ timestamps: {
+ createdAt: true,
+ updatedAt: false
+ },
timeseries: {
timeField: 'createdAt'
}
diff --git a/src/db/schemas/spendHistory.js b/src/db/schemas/spendHistory.js
index b4c3f20..d1d6c2a 100644
--- a/src/db/schemas/spendHistory.js
+++ b/src/db/schemas/spendHistory.js
@@ -11,23 +11,22 @@ export const spendHistorySchema = new Schema(
required: true,
immutable: true
},
- items: {
- type: [String],
+ reason: {
+ type: String,
required: true,
immutable: true
},
- cost: {
+ amount: {
type: Number,
required: true,
immutable: true
- },
- createdAt: {
- type: Date,
- default: Date.now,
- immutable: true
}
},
{
+ timestamps: {
+ createdAt: true,
+ updatedAt: false
+ },
timeseries: {
timeField: 'createdAt'
}
diff --git a/src/db/schemas/user.js b/src/db/schemas/user.js
index 69ebb02..55e2e67 100644
--- a/src/db/schemas/user.js
+++ b/src/db/schemas/user.js
@@ -11,23 +11,26 @@ export const AlarmType = Object.freeze({
TOILET: 'toilet'
});
-export const deviceSchema = new Schema({
- name: {
- type: String,
- required: true,
- trim: true
- },
- createdAt: {
- type: Date,
- default: Date.now,
- immutable: true
+export const deviceSchema = new Schema(
+ {
+ name: {
+ type: String,
+ required: true,
+ trim: true
+ },
+ lastSeen: {
+ type: Date,
+ default: Date.now,
+ required: true
+ }
},
- lastSeen: {
- type: Date,
- default: Date.now,
- required: true
+ {
+ timestamps: {
+ createdAt: true,
+ updatedAt: false
+ }
}
-});
+);
export const alarmSchema = new Schema(
{
@@ -49,66 +52,69 @@ export const alarmSchema = new Schema(
* User schema for MongoDB.
* @type {module:mongoose.Schema}
*/
-export const userSchema = new Schema({
- username: {
- type: String,
- required: true,
- unique: true,
- trim: true
- },
- passwordHash: {
- type: String,
- required: true
- },
- email: {
- type: String,
- required: true,
- unique: true,
- trim: true
- },
- type: {
- type: String,
- enum: Object.values(UserType),
- default: 'user'
- },
- createdAt: {
- type: Date,
- default: Date.now,
- immutable: true
- },
- devices: {
- type: [deviceSchema],
- default: []
- },
- alarm: {
- type: alarmSchema
- },
- monitoredUsers: {
- type: [ObjectId],
- ref: 'users'
- },
- balance: {
- type: Number,
- default: 0
- },
- spendHistory: {
- type: [ObjectId],
- ref: 'spendHistory'
- },
- emergencyContacts: {
- type: String
- },
- medicalInfo: {
- type: String
+export const userSchema = new Schema(
+ {
+ username: {
+ type: String,
+ required: true,
+ unique: true,
+ trim: true
+ },
+ passwordHash: {
+ type: String,
+ required: true
+ },
+ email: {
+ type: String,
+ required: true,
+ unique: true,
+ trim: true
+ },
+ type: {
+ type: String,
+ enum: Object.values(UserType),
+ default: 'user'
+ },
+ devices: {
+ type: [deviceSchema],
+ default: []
+ },
+ alarm: {
+ type: alarmSchema
+ },
+ monitoredUsers: {
+ type: [ObjectId],
+ ref: 'users'
+ },
+ balance: {
+ type: Number,
+ default: 0
+ },
+ spendHistory: {
+ type: [ObjectId],
+ ref: 'spendHistory'
+ },
+ emergencyContacts: {
+ type: String
+ },
+ medicalInfo: {
+ type: String
+ },
+ location: {
+ // https://stackoverflow.com/a/27218808
+ // Longtitute, Latitude info
+ type: [Number],
+ index: { type: '2dsphere', sparse: true },
+ count: 2
+ }
},
- location: {
- // https://stackoverflow.com/a/27218808
- // Longtitute, Latitude info
- type: [Number],
- index: { type: '2dsphere', sparse: true },
- count: 2
+ {
+ timestamps: {
+ createdAt: true,
+ updatedAt: false
+ }
}
-});
+);
export const DbUser = model('user', userSchema);
diff --git a/src/dto/auth/RegisterDto.js b/src/dto/auth/RegisterDto.js
index 40f1959..fa319b2 100644
--- a/src/dto/auth/RegisterDto.js
+++ b/src/dto/auth/RegisterDto.js
@@ -22,15 +22,6 @@ export class RegisterDto {
}
}
- try {
- return await RegisterDto.schema.validateAsync(obj);
- } catch (e) {
- console.log(e);
- throw new SafeNSoundError({
- errCode: 'JOI_VALIDATION_ERROR',
- message: e.message,
- validation_details: e.details
- });
- }
+ return await RegisterDto.schema.validateAsync(obj);
}
}
diff --git a/testFrontend/SafeNSound.FakeUser/MonitorService.cs b/testFrontend/SafeNSound.FakeUser/MonitorService.cs
index 57d90a5..83c9c29 100644
--- a/testFrontend/SafeNSound.FakeUser/MonitorService.cs
+++ b/testFrontend/SafeNSound.FakeUser/MonitorService.cs
@@ -1,14 +1,21 @@
+using System.Net;
using ArcaneLibs.Extensions;
namespace SafeNSound.FakeUser;
-public class MonitorService(ILogger<MonitorService> logger, UserStore userStore): IHostedService {
- private Task? _getAllAlarmsTask, _assignBudgetTask;
+public class MonitorService(ILogger<MonitorService> logger, UserStore userStore) : IHostedService {
+ private Task? _getAllAlarmsTask,
+ _assignBudgetTask,
+ _getAlarmForRandomUserTask,
+ _rotateMonitoredUsersTask;
+
private readonly CancellationTokenSource _cts = new();
-
+
public async Task StartAsync(CancellationToken cancellationToken) {
_getAllAlarmsTask = GetAllAlarms(_cts.Token);
_assignBudgetTask = AssignBudget(_cts.Token);
+ _getAlarmForRandomUserTask = GetAlarmForRandomUser(_cts.Token);
+ _rotateMonitoredUsersTask = RotateMonitoredUsers(_cts.Token);
}
private async Task GetAllAlarms(CancellationToken cancellationToken) {
@@ -16,37 +23,96 @@ public class MonitorService(ILogger<MonitorService> logger, UserStore userStore)
try {
var user = userStore.GetRandomMonitor();
var alarms = await user.Client!.GetAllAlarms();
- if(alarms.Count > 0)
+ if (alarms.Count > 0) {
logger.LogInformation("Monitor {UserId} has outstanding alarms: {Alarm}", user.Auth.Username, alarms.ToJson(indent: false));
- // else
- // logger.LogInformation("Monitor {UserId} found no alarms to query", user.Auth.Username);
+ await user.Client!.GetAlarm(alarms.Keys.First());
+ await user.Client!.DeleteAlarm(alarms.Keys.First());
+ }
+
await Task.Delay(TimeSpan.FromSeconds(1), cancellationToken);
- } catch (Exception ex) {
- logger.LogError(ex, "Error querying alarm");
+ }
+ catch (Exception ex) {
+ logger.LogError(ex, "Error querying alarm list");
}
}
}
-
- private async Task AssignBudget(CancellationToken cancellationToken) {
+
+ private async Task GetAlarmForRandomUser(CancellationToken cancellationToken) {
while (!cancellationToken.IsCancellationRequested) {
try {
var user = userStore.GetRandomMonitor();
- // var alarms = await user.Client!.GetAllAlarms();
- // if(alarms.Count > 0)
- // logger.LogInformation("Monitor {UserId} has outstanding alarms: {Alarm}", user.Auth.Username, alarms.ToJson(indent: false));
- // else
- // logger.LogInformation("Monitor {UserId} found no alarms to query", user.Auth.Username);
- await Task.Delay(TimeSpan.FromSeconds(1), cancellationToken);
+ var watchedUserIds = await user.Client!.GetAssignedUsers();
+ if (watchedUserIds.Count == 0) {
+ logger.LogInformation("Monitor {UserId} has no assigned users", user.Auth.Username);
+ continue;
+ }
- } catch (Exception ex) {
- logger.LogError(ex, "Error querying alarm");
+ await Task.Delay(TimeSpan.FromSeconds(1), cancellationToken);
+ }
+ catch (Exception ex) {
+ logger.LogError(ex, "Error querying alarm for user");
}
+ }
+ }
+
+ private async Task RotateMonitoredUsers(CancellationToken cancellationToken) {
+ while (!cancellationToken.IsCancellationRequested) {
+ try {
+ var user = userStore.GetRandomMonitor();
+ var watchedUserIds = (await user.Client!.GetAssignedUsers()).ToArray();
+ if (watchedUserIds.Length == 0) {
+ logger.LogInformation("Monitor {UserId} has no assigned users", user.Auth.Username);
+ }
+ else {
+ var idToRemove = Random.Shared.GetItems(watchedUserIds, 1).First();
+ await user.Client!.RemoveAssignedUser(idToRemove);
+ }
+
+ string idToAdd;
+ do {
+ idToAdd = userStore.GetRandomUserOfAnyType().WhoAmI!.UserId;
+ } while (watchedUserIds.Contains(idToAdd));
+ await user.Client!.AddAssignedUser(idToAdd);
+
+ await Task.Delay(TimeSpan.FromSeconds(1), cancellationToken);
+ }
+ catch (Exception ex) {
+ logger.LogError(ex, "Error rotating monitored users");
+ }
+ }
+ }
+
+ private async Task AssignBudget(CancellationToken cancellationToken) {
+ while (!cancellationToken.IsCancellationRequested) {
+ try {
+ var user = userStore.GetRandomMonitor();
+ var monitoredUsers = (await user.Client!.GetAssignedUsers()).ToArray();
+ if (monitoredUsers.Length == 0) {
+ logger.LogInformation("Monitor {UserId} has no assigned users", user.Auth.Username);
+ continue;
+ }
+ var userId = Random.Shared.GetItems(monitoredUsers, 1).First();
+ var budget = Random.Shared.NextDouble();
+ 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) {
+ logger.LogError(ex, "Error assigning budget");
+ }
}
}
public async Task StopAsync(CancellationToken cancellationToken) {
await _cts.CancelAsync();
await _getAllAlarmsTask!;
+ await _assignBudgetTask!;
+ await _getAlarmForRandomUserTask!;
+ await _rotateMonitoredUsersTask!;
}
}
\ No newline at end of file
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 7835f89..0000000
--- a/testFrontend/SafeNSound.FakeUser/RandomAlarmService.cs
+++ /dev/null
@@ -1,35 +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 async Task Run(CancellationToken cancellationToken) {
- while (!cancellationToken.IsCancellationRequested) {
- try {
- var user = userStore.GetRandomUser();
- if (Random.Shared.Next(100) > 90) {
- await user.Client!.SetAlarm(new Sdk.AlarmDto {
- Reason = "fall"
- });
- }
- else {
- await user.Client!.DeleteAlarm();
- }
- }
- catch (Exception ex) {
- Console.WriteLine($"Error setting/deleting alarm: {ex.Message}");
- }
-
- await Task.Delay(TimeSpan.FromMilliseconds(100), 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.FakeUser/UserStore.cs b/testFrontend/SafeNSound.FakeUser/UserStore.cs
index 9b04efb..f643a68 100644
--- a/testFrontend/SafeNSound.FakeUser/UserStore.cs
+++ b/testFrontend/SafeNSound.FakeUser/UserStore.cs
@@ -5,7 +5,7 @@ namespace SafeNSound.FakeUser;
public class UserStore(SafeNSoundAuthentication authService, SafeNSoundConfiguration config) : IHostedService {
public List<ClientContainer> Admins { get; } = Enumerable.Range(0, 1).Select(_ => new ClientContainer()).ToList();
public List<ClientContainer> Monitors { get; } = Enumerable.Range(0, 5).Select(_ => new ClientContainer()).ToList();
- public List<ClientContainer> Users { get; } = Enumerable.Range(0, 150000).Select(_ => new ClientContainer()).ToList();
+ public List<ClientContainer> Users { get; } = Enumerable.Range(0, 15).Select(_ => new ClientContainer()).ToList();
public List<ClientContainer> AllUsers => [.. Users, .. Monitors, .. Admins];
public ClientContainer GetRandomUser() {
@@ -51,8 +51,16 @@ public class UserStore(SafeNSoundAuthentication authService, SafeNSoundConfigura
var tasks = ((ClientContainer[]) [..Users, ..Monitors, ..Admins]).Select(async container => {
await ss.WaitAsync();
await authService.Register(container.Auth);
- // container.Client = new SafeNSoundClient(config, (await authService.Login(container.Auth)).AccessToken);
- // container.WhoAmI = await container.Client.WhoAmI();
+ container.Client = new SafeNSoundClient(config, (await authService.Login(container.Auth)).AccessToken);
+ container.WhoAmI = await container.Client.WhoAmI();
+ await container.Client.UpdateDevice(container.WhoAmI.DeviceId, new() {
+ Name = "FakeUser-" + container.Auth.Username,
+ });
+
+ if (container.Auth.UserType == "admin") {
+ await container.Client.MonitorAllUsers();
+ }
+
ss.Release();
}).ToList();
await Task.WhenAll(tasks);
@@ -76,6 +84,10 @@ public class UserStore(SafeNSoundAuthentication authService, SafeNSoundConfigura
private async Task Cleanup(ClientContainer container) {
if (container.Client == null) return;
try {
+ _ = await container.Client.GetDevices();
+ if(Random.Shared.Next(100)>50)
+ await container.Client.DeleteDevice(container.WhoAmI!.DeviceId);
+ else await container.Client.LogOut();
await container.Client.DeleteAccount(container.Auth);
}
catch {
diff --git a/testFrontend/SafeNSound.Frontend/Pages/Auth.razor b/testFrontend/SafeNSound.Frontend/Pages/Auth.razor
index 5540f02..20625f7 100644
--- a/testFrontend/SafeNSound.Frontend/Pages/Auth.razor
+++ b/testFrontend/SafeNSound.Frontend/Pages/Auth.razor
@@ -3,13 +3,13 @@
<h1>Auth</h1>
<u>User:</u><br/>
<span>Username (L?, R): </span>
-<FancyTextBox @bind-Value="@Username"/><br/>
+<FancyTextBox @bind-Value="@AuthData.Username"/><br/>
<span>Email (L? R): </span>
-<FancyTextBox @bind-Value="@Email"/><br/>
+<FancyTextBox @bind-Value="@AuthData.Email"/><br/>
<span>Password (L, R): </span>
-<FancyTextBox @bind-Value="@Password" IsPassword="true"/><br/>
+<FancyTextBox @bind-Value="@AuthData.Password" IsPassword="true"/><br/>
<span>Type (R): </span>
-<FancyTextBox @bind-Value="@UserType"/><span> (one of user|monitor|admin)</span><br/>
+<FancyTextBox @bind-Value="@AuthData.UserType"/><span> (one of user|monitor|admin)</span><br/>
<LinkButton OnClick="@Randomise">Randomise</LinkButton>
<LinkButton OnClick="@Register">Register</LinkButton>
<LinkButton OnClick="@Login">Login</LinkButton>
@@ -24,6 +24,20 @@
<LinkButton OnClick="@GetAssignedUsers">Get</LinkButton>
<LinkButton OnClick="@AddAssignedUser">Add</LinkButton>
<LinkButton OnClick="@RemoveAssignedUser">Remove</LinkButton>
+<br/><br/>
+
+<u>Devices:</u><br/>
+@if (CurrentDevice is not null) {
+ <span>Device ID: @CurrentDevice.Id</span><br/>
+ <span>Log in date: @CurrentDevice.CreatedAt</span><br/>
+ <span>Last seen: @CurrentDevice.LastSeen</span><br/>
+ <span>Device name: </span>
+ <FancyTextBox @bind-Value="@CurrentDevice.Name"/><br/>
+ <LinkButton OnClick="@GetDevice">Get</LinkButton>
+ <LinkButton OnClick="@UpdateDevice">Update</LinkButton>
+ <LinkButton OnClick="@DeleteDevice">Delete</LinkButton>
+ <LinkButton OnClick="@GetAllDevices">Get all</LinkButton>
+}
@if (Exception != null) {
<div class="alert alert-danger">
@@ -44,10 +58,15 @@
}
@code {
- private string Username { get; set; } = string.Empty;
- private string Email { get; set; } = string.Empty;
- private string Password { get; set; } = string.Empty;
- private string UserType { get; set; } = string.Empty;
+
+ private RegisterDto AuthData { get; set; } = new() {
+ Username = string.Empty,
+ UserType = string.Empty,
+ Email = String.Empty,
+ Password = string.Empty
+ };
+
+ private DeviceDto? CurrentDevice { get; set; }
private string TargetUserId { get; set; } = string.Empty;
@@ -55,10 +74,10 @@
private object? Result { get; set; }
private async Task Randomise() {
- Username = Guid.NewGuid().ToString();
- Email = Guid.NewGuid() + "@example.com";
- Password = Guid.NewGuid().ToString();
- UserType = Random.Shared.GetItems(["user", "monitor", "admin"], 1)[0];
+ AuthData.Username = Guid.NewGuid().ToString();
+ AuthData.Email = Guid.NewGuid() + "@example.com";
+ AuthData.Password = Guid.NewGuid().ToString();
+ AuthData.UserType = Random.Shared.GetItems(["user", "monitor", "admin"], 1)[0];
StateHasChanged();
}
@@ -67,10 +86,10 @@
Exception = null;
try {
await Authentication.Register(new() {
- Username = Username,
- Password = Password,
- Email = Email,
- UserType = UserType
+ Username = AuthData.Username,
+ Password = AuthData.Password,
+ Email = AuthData.Email,
+ UserType = AuthData.UserType
});
}
catch (Exception ex) {
@@ -86,11 +105,14 @@
try {
AuthResult result;
Result = result = await Authentication.Login(new() {
- Username = Username,
- Password = Password,
- Email = Email
+ Username = AuthData.Username,
+ Password = AuthData.Password,
+ Email = AuthData.Email
});
App.Client = new SafeNSoundClient(Config, result.AccessToken);
+ CurrentDevice = await App.Client.GetDevice(
+ (await App.Client.WhoAmI()).DeviceId
+ );
}
catch (Exception ex) {
Exception = ex;
@@ -104,9 +126,9 @@
Exception = null;
try {
await Authentication.Delete(new() {
- Username = Username,
- Password = Password,
- Email = Email
+ Username = AuthData.Username,
+ Password = AuthData.Password,
+ Email = AuthData.Email
});
}
catch (Exception ex) {
@@ -125,6 +147,7 @@
catch (Exception ex) {
Exception = ex;
}
+
StateHasChanged();
}
@@ -137,6 +160,7 @@
catch (Exception ex) {
Exception = ex;
}
+
StateHasChanged();
}
@@ -150,6 +174,7 @@
catch (Exception ex) {
Exception = ex;
}
+
StateHasChanged();
}
@@ -163,6 +188,7 @@
catch (Exception ex) {
Exception = ex;
}
+
StateHasChanged();
}
@@ -185,6 +211,28 @@
catch (Exception ex) {
Exception = ex;
}
+
+ StateHasChanged();
+ }
+
+ private async Task GetDevice() {
+ Result = CurrentDevice = await App.Client!.GetDevice(CurrentDevice!.Id!);
+ StateHasChanged();
+ }
+
+ private async Task UpdateDevice() {
+ await App.Client!.UpdateDevice(CurrentDevice!.Id!, new() {
+ Name = CurrentDevice.Name
+ });
+ await GetDevice();
+ }
+
+ private async Task DeleteDevice() {
+ await App.Client!.DeleteDevice(CurrentDevice!.Id!);
+ }
+
+ private async Task GetAllDevices() {
+ Result = await App.Client!.GetDevices();
StateHasChanged();
}
diff --git a/testFrontend/SafeNSound.Sdk/SafeNSoundClient.cs b/testFrontend/SafeNSound.Sdk/SafeNSoundClient.cs
index 8291178..e1564db 100644
--- a/testFrontend/SafeNSound.Sdk/SafeNSoundClient.cs
+++ b/testFrontend/SafeNSound.Sdk/SafeNSoundClient.cs
@@ -4,50 +4,52 @@ using System.Text.Json.Serialization;
namespace SafeNSound.Sdk;
-public class SafeNSoundClient(SafeNSoundConfiguration config, string accessToken)
-{
- public WrappedHttpClient HttpClient { get; } = new()
- {
+public class SafeNSoundClient(SafeNSoundConfiguration config, string accessToken) {
+ public WrappedHttpClient HttpClient { get; } = new() {
BaseAddress = new Uri(config.BaseUri),
- DefaultRequestHeaders =
- {
+ DefaultRequestHeaders = {
Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", accessToken)
}
};
-
- public async Task<WhoAmI> WhoAmI()
- {
+
+ public async Task<WhoAmI> WhoAmI() {
var res = await HttpClient.GetAsync("/auth/whoami");
res.EnsureSuccessStatusCode();
return (await res.Content.ReadFromJsonAsync<WhoAmI>())!;
}
-
+
#region Alarm
- public async Task<AlarmDto> GetAlarm(string userId = "@me") {
- var res = await HttpClient.GetAsync($"/alarm/{userId}");
+ public async Task SetAlarm(AlarmDto alarm) {
+ var res = await HttpClient.PutAsJsonAsync("/alarm/@me", alarm);
res.EnsureSuccessStatusCode();
- return (await res.Content.ReadFromJsonAsync<AlarmDto>())!;
}
-
- public async Task SetAlarm(AlarmDto alarm, string userId = "@me") {
- var res = await HttpClient.PutAsJsonAsync("/alarm/@me", alarm);
+
+ public async Task<AlarmDto?> GetAlarm(string userId = "@me") {
+ var res = await HttpClient.GetAsync(
+ // required due to express routing not being closest-match
+ userId == "@me"
+ ? $"/alarm/@me"
+ : $"/user/{userId}/alarm"
+ );
res.EnsureSuccessStatusCode();
+ return (await res.Content.ReadFromJsonAsync<AlarmDto?>());
}
-
+
public async Task DeleteAlarm(string userId = "@me") {
- var res = await HttpClient.DeleteAsync($"/alarm/{userId}");
+ var res = await HttpClient.DeleteAsync(
+ // required due to express routing not being closest-match
+ userId == "@me"
+ ? $"/alarm/@me"
+ : $"/user/{userId}/alarm"
+ );
res.EnsureSuccessStatusCode();
}
-
-
#endregion
#region Budget
-
-
#endregion
public async Task<Dictionary<string, AlarmDto>> GetAllAlarms() {
@@ -89,13 +91,95 @@ public class SafeNSoundClient(SafeNSoundConfiguration config, string accessToken
var res = await HttpClient.PostAsync("/admin/monitorAllUsers", null);
res.EnsureSuccessStatusCode();
}
-}
+ public async Task<List<DeviceDto>> GetDevices() {
+ var res = await HttpClient.GetAsync("/auth/devices");
+ res.EnsureSuccessStatusCode();
+ return (await res.Content.ReadFromJsonAsync<List<DeviceDto>>())!;
+ }
+
+ public async Task<DeviceDto> GetDevice(string deviceId) {
+ var res = await HttpClient.GetAsync($"/auth/devices/{deviceId}");
+ res.EnsureSuccessStatusCode();
+ return (await res.Content.ReadFromJsonAsync<DeviceDto>())!;
+ }
+
+ public async Task DeleteDevice(string deviceId) {
+ var res = await HttpClient.DeleteAsync($"/auth/devices/{deviceId}");
+ res.EnsureSuccessStatusCode();
+ }
+
+ public async Task UpdateDevice(string deviceId, DeviceDto device) {
+ var res = await HttpClient.PatchAsJsonAsync($"/auth/devices/{deviceId}", device);
+ res.EnsureSuccessStatusCode();
+ }
+
+ public async Task LogOut() {
+ var res = await HttpClient.PostAsync("/auth/logout", null);
+ res.EnsureSuccessStatusCode();
+ }
+
+ 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/@me"
+ : $"/user/{userId}/budget"
+ );
+ res.EnsureSuccessStatusCode();
+ return (await res.Content.ReadFromJsonAsync<BudgetWithHistory>())!;
+ }
+}
public class AlarmDto {
[JsonPropertyName("reason")]
public required string Reason { get; set; }
-
+
+ [JsonPropertyName("createdAt")]
+ public DateTime CreatedAt { get; set; }
+}
+
+public class DeviceDto {
+ [JsonPropertyName("_id")]
+ public string? Id { get; set; }
+
+ [JsonPropertyName("name")]
+ public string? Name { get; set; }
+
[JsonPropertyName("createdAt")]
public DateTime CreatedAt { get; set; }
+
+ [JsonPropertyName("lastSeen")]
+ public DateTime LastSeen { get; set; }
+}
+
+public class BudgetWithHistory {
+ [JsonPropertyName("budget")]
+ public double Amount { get; set; }
+
+ [JsonPropertyName("history")]
+ public List<BudgetHistoryEntry> History { get; set; } = new();
+}
+
+public class BudgetHistoryEntry {
+ [JsonPropertyName("venue")]
+ public string Venue { get; set; }
+
+ [JsonPropertyName("amount")]
+ public double Amount { get; set; }
+
+ [JsonPropertyName("reason")]
+ public string Reason { get; set; }
+
+ [JsonPropertyName("createdAt")]
+ 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 ae9fb74..1235bc1 100644
--- a/testFrontend/SafeNSound.sln.DotSettings.user
+++ b/testFrontend/SafeNSound.sln.DotSettings.user
@@ -1,4 +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>
|