4 Commits 15428b4274 ... fd8caed83a

Author SHA1 Message Date
  Owen Diffey fd8caed83a refactor: Separates model classes 1 month ago
  Owen Diffey 2924cc0727 feat: Add shellcheck to musare.sh 1 month ago
  Owen Diffey d0784669ed refactor(musare.sh): Improve handling and styling 1 month ago
  Kristian Vos a33774914c refactor: improve how models are fetched by batching such requests, like bulk model permission request fetching 1 month ago

+ 2 - 0
.github/workflows/build-lint.yml

@@ -42,3 +42,5 @@ jobs:
 #              run: ./musare.sh typescript frontend
             - name: Docs Lint
               run: ./musare.sh lint docs
+            - name: Shell Lint
+              run: ./musare.sh lint shell

+ 2 - 2
backend/entrypoint.dev.sh

@@ -2,13 +2,13 @@
 
 set -e
 
-if [[ "${BACKEND_DEBUG}" == "true" ]]; then
+if [ "${BACKEND_DEBUG}" = "true" ]; then
     export INSPECT_BRK="--inspect-brk=0.0.0.0:${BACKEND_DEBUG_PORT:-9229}"
 else
     export INSPECT_BRK=""
 fi
 
-if [[ "${APP_ENV}" == "development" ]]; then
+if [ "${APP_ENV}" = "development" ]; then
     npm run dev
 else
     npm run prod

+ 15 - 0
common/DeferredPromise.ts

@@ -0,0 +1,15 @@
+export class DeferredPromise<T = any> {
+	promise: Promise<T>;
+
+	reject;
+
+	resolve;
+
+	// eslint-disable-next-line require-jsdoc
+	constructor() {
+		this.promise = new Promise<T>((resolve, reject) => {
+			this.reject = reject;
+			this.resolve = resolve;
+		});
+	}
+}

+ 1 - 1
frontend/entrypoint.dev.sh

@@ -2,7 +2,7 @@
 
 set -e
 
-if [ "${APP_ENV}" == "development" ]; then
+if [ "${APP_ENV}" = "development" ]; then
     ln -sf /opt/app/nginx.dev.conf /etc/nginx/http.d/default.conf
     nginx
 

+ 2 - 110
frontend/src/Model.ts

@@ -3,115 +3,7 @@
 import { forEachIn } from "@common/utils/forEachIn";
 import { useModelStore } from "./stores/model";
 import { useWebsocketStore } from "./stores/websocket";
-
-class DeferredPromise<T = any> {
-	promise: Promise<T>;
-
-	reject;
-
-	resolve;
-
-	// eslint-disable-next-line require-jsdoc
-	constructor() {
-		this.promise = new Promise<T>((resolve, reject) => {
-			this.reject = reject;
-			this.resolve = resolve;
-		});
-	}
-}
-
-interface ModelPermissionFetcherRequest {
-	promise: DeferredPromise;
-	payload: {
-		modelName: string;
-		modelId: string;
-	};
-}
-
-/**
- * Class used for fetching model permissions in bulk, every 25ms max
- * So if there's 200 models loaded, it would do only 1 request to fetch model permissions, not 200 separate ones
- */
-class ModelPermissionFetcher {
-	private static requestsQueued: ModelPermissionFetcherRequest[] = [];
-
-	private static timeoutActive = false;
-
-	private static fetch() {
-		// If there is no other timeout running, indicate we will run one. Otherwise, return, as a timeout is already running
-		if (!this.timeoutActive) this.timeoutActive = true;
-		else return;
-
-		setTimeout(() => {
-			// Reset timeout active, so another one can run
-			this.timeoutActive = false;
-			// Make a copy of all requests currently queued, and then take those requests out of the queue so we can request them
-			const requests = this.requestsQueued;
-			this.requestsQueued = [];
-
-			// Splits the requests per model
-			const requestsPerModel = {};
-			requests.forEach(request => {
-				const { modelName } = request.payload;
-				if (!Array.isArray(requestsPerModel[modelName]))
-					requestsPerModel[modelName] = [];
-				requestsPerModel[modelName].push(request);
-			});
-
-			const modelNames = Object.keys(requestsPerModel);
-
-			const { runJob } = useWebsocketStore();
-
-			// Runs the requests per model
-			forEachIn(modelNames, async modelName => {
-				// Gets a unique list of all model ids for the current model that we want to request permissions for
-				const modelIds = Array.from(
-					new Set(
-						requestsPerModel[modelName].map(
-							request => request.payload.modelId
-						)
-					)
-				);
-
-				const result = await runJob("data.users.getModelPermissions", {
-					modelName,
-					modelIds
-				});
-
-				const requests = requestsPerModel[modelName];
-				// For all requests, resolve the deferred promise with the returned permissions for the model that request requested
-				requests.forEach(request => {
-					const { payload, promise } = request;
-					const { modelId } = payload;
-					promise.resolve(result[modelId]);
-				});
-			});
-		}, 25);
-	}
-
-	public static fetchModelPermissions(modelName, modelId) {
-		return new Promise(resolve => {
-			const promise = new DeferredPromise();
-
-			// Listens for the deferred promise response, before we actually push and fetch
-			promise.promise.then(result => {
-				resolve(result);
-			});
-
-			// Pushes the request to the queue
-			this.requestsQueued.push({
-				payload: {
-					modelName,
-					modelId
-				},
-				promise
-			});
-
-			// Calls the fetch function, which will start a timeout if one isn't already running, which will actually request the permissions
-			this.fetch();
-		});
-	}
-}
+import { ModelPermissionFetcher } from "./ModelPermissionFetcher";
 
 export default class Model {
 	private _permissions?: object;
@@ -296,7 +188,7 @@ export default class Model {
 
 		this._permissions = await ModelPermissionFetcher.fetchModelPermissions(
 			this.getName(),
-			this._id
+			this.getId()
 		);
 
 		return this._permissions;

+ 153 - 0
frontend/src/ModelFetcher.ts

@@ -0,0 +1,153 @@
+import { DeferredPromise } from "@common/DeferredPromise";
+import { forEachIn } from "@common/utils/forEachIn";
+import { useWebsocketStore } from "@/stores/websocket";
+
+export interface ModelFetcherRequest {
+	promise: DeferredPromise;
+	payload: {
+		modelName: string;
+		modelIds: string[];
+	};
+}
+
+// TODO combine the ModelFetcher and the ModelPermissionFetcher
+/**
+ * Class used for fetching models in bulk, every 25ms max, per model type
+ * So if we tried to fetch 100 different minifiedUser models separately, it would do only 1 request to fetch the models, not 100 separate ones
+ */
+export class ModelFetcher {
+	private static requestsQueued: ModelFetcherRequest[] = [];
+
+	private static timeoutActive = false;
+
+	private static responseCache = {};
+
+	private static fetch() {
+		// If there is no other timeout running, indicate we will run one. Otherwise, return, as a timeout is already running
+		if (!this.timeoutActive) this.timeoutActive = true;
+		else return;
+
+		setTimeout(() => {
+			// Reset timeout active, so another one can run
+			this.timeoutActive = false;
+			// Make a copy of all requests currently queued, and then take those requests out of the queue so we can request them
+			const requests = this.requestsQueued;
+			this.requestsQueued = [];
+
+			// Splits the requests per model
+			const requestsPerModel = {};
+			requests.forEach(request => {
+				const { modelName } = request.payload;
+				if (!Array.isArray(requestsPerModel[modelName]))
+					requestsPerModel[modelName] = [];
+				requestsPerModel[modelName].push(request);
+			});
+
+			const modelNames = Object.keys(requestsPerModel);
+
+			const { runJob } = useWebsocketStore();
+
+			// TODO somehow make the following forEachIn run at the same time for all modelNames
+			// Runs the requests per model
+			forEachIn(modelNames, async modelName => {
+				// All already cached model ids
+				let cachedModelIds = Object.keys(this.responseCache[modelName]);
+
+				// Gets a unique list of all model ids for the current model that we want to request permissions for, that are not already cached
+				const modelIds = Array.from(
+					new Set(
+						requestsPerModel[modelName].flatMap(
+							request => request.payload.modelIds
+						)
+					)
+				).filter(
+					(modelId: string) => !cachedModelIds.includes(modelId)
+				);
+
+				// Only do a request if more than one model isn't already cached
+				if (modelIds.length > 0) {
+					console.log(`Requesting model ids`, modelName, modelIds);
+					const result = (await runJob(
+						`data.${modelName}.findManyById`,
+						{
+							_ids: modelIds
+						}
+					)) as any[];
+
+					// Cache the responses for the requested model ids
+					modelIds.forEach(modelId => {
+						const model = result.find(
+							model => model._id === modelId
+						);
+						console.log(`Caching ${modelName}.${modelId}`, model);
+						this.responseCache[modelName][modelId] = model;
+					});
+				}
+
+				const requests = requestsPerModel[modelName];
+				// For all requests, resolve the deferred promise with the returned model(s) that was requested
+				requests.forEach(request => {
+					const { payload, promise } = request;
+					const { modelIds } = payload;
+					const models = modelIds
+						.map(modelId => this.responseCache[modelName][modelId])
+						.filter(model => model);
+					promise.resolve(models);
+				});
+
+				// A unique list of model ids that are will be requested in the next batch for the current model type
+				const queuedModelIds = Array.from(
+					new Set(
+						this.requestsQueued
+							.filter(request => request.payload.modelName)
+							.flatMap(request => request.payload.modelIds)
+					)
+				);
+				// A list of model ids responses currently cached
+				cachedModelIds = Object.keys(this.responseCache[modelName]);
+				// A list of the cached model responses that can safely be deleted, because no queued up request needs it
+				const cachedModelIdsToDelete = cachedModelIds.filter(
+					cachedModelId => !queuedModelIds.includes(cachedModelId)
+				);
+				console.log(`Queued model ids`, modelName, queuedModelIds);
+				console.log(`Cached model ids`, modelName, cachedModelIds);
+				console.log(
+					`Cached model ids to delete`,
+					modelName,
+					cachedModelIdsToDelete
+				);
+
+				// TODO In theory, we could check if any of the queued requests can be resolved here. Not worth it at the moment.
+
+				cachedModelIdsToDelete.forEach(cachedModelIdToDelete => {
+					delete this.responseCache[modelName][cachedModelIdToDelete];
+				});
+			});
+		}, 25);
+	}
+
+	public static fetchModelsByIds(modelName: string, modelIds: string[]) {
+		this.responseCache[modelName] ??= {};
+
+		return new Promise(resolve => {
+			const promise = new DeferredPromise();
+
+			// Listens for the deferred promise response, before we actually push and fetch
+			promise.promise.then(result => {
+				resolve(result);
+			});
+
+			// Pushes the request to the queue
+			this.requestsQueued.push({
+				payload: {
+					modelName,
+					modelIds
+				},
+				promise
+			});
+
+			// Calls the fetch function, which will start a timeout if one isn't already running, which will actually request the model
+			this.fetch();
+		});
+	}
+}

+ 96 - 0
frontend/src/ModelPermissionFetcher.ts

@@ -0,0 +1,96 @@
+import { DeferredPromise } from "@common/DeferredPromise";
+import { forEachIn } from "@common/utils/forEachIn";
+import { useWebsocketStore } from "./stores/websocket";
+
+export interface ModelPermissionFetcherRequest {
+	promise: DeferredPromise;
+	payload: {
+		modelName: string;
+		modelId: string;
+	};
+}
+
+/**
+ * Class used for fetching model permissions in bulk, every 25ms max
+ * So if there's 200 models loaded, it would do only 1 request to fetch model permissions, not 200 separate ones
+ */
+export class ModelPermissionFetcher {
+	private static requestsQueued: ModelPermissionFetcherRequest[] = [];
+
+	private static timeoutActive = false;
+
+	private static fetch() {
+		// If there is no other timeout running, indicate we will run one. Otherwise, return, as a timeout is already running
+		if (!this.timeoutActive) this.timeoutActive = true;
+		else return;
+
+		setTimeout(() => {
+			// Reset timeout active, so another one can run
+			this.timeoutActive = false;
+			// Make a copy of all requests currently queued, and then take those requests out of the queue so we can request them
+			const requests = this.requestsQueued;
+			this.requestsQueued = [];
+
+			// Splits the requests per model
+			const requestsPerModel = {};
+			requests.forEach(request => {
+				const { modelName } = request.payload;
+				if (!Array.isArray(requestsPerModel[modelName]))
+					requestsPerModel[modelName] = [];
+				requestsPerModel[modelName].push(request);
+			});
+
+			const modelNames = Object.keys(requestsPerModel);
+
+			const { runJob } = useWebsocketStore();
+
+			// Runs the requests per model
+			forEachIn(modelNames, async modelName => {
+				// Gets a unique list of all model ids for the current model that we want to request permissions for
+				const modelIds = Array.from(
+					new Set(
+						requestsPerModel[modelName].map(
+							request => request.payload.modelId
+						)
+					)
+				);
+
+				const result = await runJob("data.users.getModelPermissions", {
+					modelName,
+					modelIds
+				});
+
+				const requests = requestsPerModel[modelName];
+				// For all requests, resolve the deferred promise with the returned permissions for the model that request requested
+				requests.forEach(request => {
+					const { payload, promise } = request;
+					const { modelId } = payload;
+					promise.resolve(result[modelId]);
+				});
+			});
+		}, 25);
+	}
+
+	public static fetchModelPermissions(modelName, modelId) {
+		return new Promise(resolve => {
+			const promise = new DeferredPromise();
+
+			// Listens for the deferred promise response, before we actually push and fetch
+			promise.promise.then(result => {
+				resolve(result);
+			});
+
+			// Pushes the request to the queue
+			this.requestsQueued.push({
+				payload: {
+					modelName,
+					modelId
+				},
+				promise
+			});
+
+			// Calls the fetch function, which will start a timeout if one isn't already running, which will actually request the permissions
+			this.fetch();
+		});
+	}
+}

+ 10 - 9
frontend/src/stores/model.ts

@@ -4,6 +4,7 @@ import { generateUuid } from "@common/utils/generateUuid";
 import { forEachIn } from "@common/utils/forEachIn";
 import { useWebsocketStore } from "./websocket";
 import Model from "@/Model";
+import { ModelFetcher } from "@/ModelFetcher";
 
 /**
  * Pinia store for managing models
@@ -192,7 +193,11 @@ export const useModelStore = defineStore("model", () => {
 
 		if (existingModel) return existingModel;
 
-		return runJob(`data.${modelName}.findById`, { _id: modelId });
+		const [model] = (await ModelFetcher.fetchModelsByIds(modelName, [
+			modelId
+		])) as unknown[];
+
+		return model;
 	};
 
 	/**
@@ -213,9 +218,10 @@ export const useModelStore = defineStore("model", () => {
 
 		let fetchedModels = [];
 		if (missingModelIds.length > 0)
-			fetchedModels = (await runJob(`data.${modelName}.findManyById`, {
-				_ids: missingModelIds
-			})) as unknown[];
+			fetchedModels = (await ModelFetcher.fetchModelsByIds(
+				modelName,
+				missingModelIds
+			)) as unknown[];
 
 		const allModels = existingModels.concat(fetchedModels);
 
@@ -285,8 +291,6 @@ export const useModelStore = defineStore("model", () => {
 				return;
 			delete models[modelName][modelIdToRemove];
 		});
-
-		console.log("After unregister", JSON.parse(JSON.stringify(models)));
 	};
 
 	/**
@@ -301,7 +305,6 @@ export const useModelStore = defineStore("model", () => {
 	): Promise<Model[]> => {
 		console.info("Register models", documentsOrModels, relations);
 
-		console.log(123123, documentsOrModels);
 		const existingModels = documentsOrModels
 			.map(({ _name, _id }) =>
 				models[_name] ? models[_name][_id] ?? null : null
@@ -414,7 +417,6 @@ export const useModelStore = defineStore("model", () => {
 		);
 
 		const fetchedModels = await findManyById(modelName, missingModelIds);
-		console.log(999, modelName, missingModelIds, fetchedModels);
 		const registeredModels = await registerModels(
 			Object.values(fetchedModels)
 				.filter(model => !!model)
@@ -428,7 +430,6 @@ export const useModelStore = defineStore("model", () => {
 			)
 			.map(modelId => [modelId, null]);
 
-		console.log(123, registeredModels, modelsNotFound, fetchedModels);
 		return Object.fromEntries(
 			registeredModels
 				.map(model => [model.getId(), model])

+ 500 - 422
musare.sh

@@ -1,36 +1,50 @@
 #!/bin/bash
 
+set -e
+
 export PATH=/usr/local/bin:/usr/bin:/bin
 
+# Color variables
 CYAN='\033[33;36m';
 RED='\033[0;31m'
 YELLOW='\033[0;93m'
 GREEN='\033[0;32m'
 NC='\033[0m'
 
+# Print provided formatted error and exit with code (default 1)
+throw()
+{
+    echo -e "${RED}${1}${NC}"
+    exit "${2:-1}"
+}
+
+# Navigate to repository
 scriptLocation=$(dirname -- "$(readlink -fn -- "$0"; echo x)")
-cd "${scriptLocation%x}" || exit 1
-
-if [[ -f .env ]]; then
-    # shellcheck disable=SC1091
-    source .env
-else
-    echo -e "${RED}Error: .env does not exist${NC}"
-    exit 2
+cd "${scriptLocation%x}"
+
+# Import environment variables
+if [[ ! -f .env ]]; then
+    throw "Error: .env does not exist" 2
 fi
+# shellcheck disable=SC1091
+source .env
 
-if [[ -z ${DOCKER_COMMAND} ]]; then
-    DOCKER_COMMAND="docker"
-elif [[ ${DOCKER_COMMAND} != "docker" && ${DOCKER_COMMAND} != "podman" ]]; then
-    echo -e "${RED}Error: Invalid DOCKER_COMMAND${NC}"
-    exit 1
+# Define docker command
+docker="${DOCKER_COMMAND:-docker}"
+if [[ ${docker} != "docker" && ${docker} != "podman" ]]; then
+    throw "Error: Invalid DOCKER_COMMAND"
 fi
 
-docker="${DOCKER_COMMAND}"
+set +e
+
+# Check if docker is installed
 ${docker} --version > /dev/null 2>&1
 dockerInstalled=$?
 
+# Define docker compose command
 dockerCompose="${docker} compose"
+
+# Check if docker compose is installed
 ${dockerCompose} version > /dev/null 2>&1
 composeInstalled=$?
 if [[ ${composeInstalled} -gt 0 ]]; then
@@ -39,17 +53,22 @@ if [[ ${composeInstalled} -gt 0 ]]; then
     composeInstalled=$?
 fi
 
+set -e
+
+# Exit if docker and/or docker compose is not installed
 if [[ ${dockerInstalled} -gt 0 || ${composeInstalled} -gt 0 ]]; then
     if [[ ${dockerInstalled} -eq 0 && ${composeInstalled} -gt 0 ]]; then
-        echo -e "${RED}Error: ${dockerCompose} not installed.${NC}"
-    elif [[ ${dockerInstalled} -gt 0 && ${composeInstalled} -eq 0 ]]; then
-        echo -e "${RED}Error: ${docker} not installed.${NC}"
-    else
-        echo -e "${RED}Error: ${docker} and ${dockerCompose} not installed.${NC}"
+        throw "Error: ${dockerCompose} not installed."
     fi
-    exit 1
+
+    if [[ ${dockerInstalled} -gt 0 && ${composeInstalled} -eq 0 ]]; then
+        throw "Error: ${docker} not installed."
+    fi
+
+    throw "Error: ${docker} and ${dockerCompose} not installed."
 fi
 
+# Add docker compose file arguments to command
 composeFiles="-f docker-compose.yml"
 if [[ ${APP_ENV} == "development" ]]; then
     composeFiles="${composeFiles} -f docker-compose.dev.yml"
@@ -59,11 +78,14 @@ if [[ -f docker-compose.override.yml ]]; then
 fi
 dockerCompose="${dockerCompose} ${composeFiles}"
 
+# Parse services from arguments string
 handleServices()
 {
+    # shellcheck disable=SC2206
     validServices=($1)
     servicesArray=()
     invalidServices=false
+
     for x in "${@:2}"; do
         if [[ ${validServices[*]} =~ (^|[[:space:]])"$x"($|[[:space:]]) ]]; then
             if ! [[ ${servicesArray[*]} =~ (^|[[:space:]])"$x"($|[[:space:]]) ]]; then
@@ -77,6 +99,7 @@ handleServices()
             fi
         fi
     done
+
     if [[ $invalidServices == false && ${#servicesArray[@]} -gt 0 ]]; then
         echo "1|${servicesArray[*]}"
     elif [[ $invalidServices == false ]]; then
@@ -86,81 +109,463 @@ handleServices()
     fi
 }
 
+# Execute a docker command
 runDockerCommand()
 {
     validCommands=(start stop restart pull build ps logs)
-    if [[ ${validCommands[*]} =~ (^|[[:space:]])"$2"($|[[:space:]]) ]]; then
-        servicesString=$(handleServices "backend frontend mongo redis" "${@:3}")
-        if [[ ${servicesString:0:1} == 1 ]]; then
-            if [[ ${servicesString:2:4} == "all" ]]; then
-                servicesString=""
-                pullServices="mongo redis"
-                buildServices="backend frontend"
+    if ! [[ ${validCommands[*]} =~ (^|[[:space:]])"$2"($|[[:space:]]) ]]; then
+        throw "Error: Invalid runDockerCommand input"
+    fi
+
+    servicesString=$(handleServices "backend frontend mongo redis" "${@:3}")
+    if [[ ${servicesString:0:1} != 1 ]]; then
+        throw "${servicesString:2}\n${YELLOW}Usage: ${1} [backend, frontend, mongo, redis]"
+    fi
+
+    if [[ ${servicesString:2:4} == "all" ]]; then
+        servicesString=""
+        pullServices="mongo redis"
+        buildServices="backend frontend"
+    else
+        servicesString=${servicesString:2}
+        pullArray=()
+        buildArray=()
+
+        if [[ "${servicesString}" == *mongo* ]]; then
+            pullArray+=("mongo")
+        fi
+
+        if [[ "${servicesString}" == *redis* ]]; then
+            pullArray+=("redis")
+        fi
+
+        if [[ "${servicesString}" == *backend* ]]; then
+            buildArray+=("backend")
+        fi
+
+        if [[ "${servicesString}" == *frontend* ]]; then
+            buildArray+=("frontend")
+        fi
+
+        pullServices="${pullArray[*]}"
+        buildServices="${buildArray[*]}"
+    fi
+
+    if [[ ${2} == "stop" || ${2} == "restart" ]]; then
+        # shellcheck disable=SC2086
+        ${dockerCompose} stop ${servicesString}
+    fi
+
+    if [[ ${2} == "start" || ${2} == "restart" ]]; then
+        # shellcheck disable=SC2086
+        ${dockerCompose} up -d ${servicesString}
+    fi
+
+    if [[ ${2} == "pull" && ${pullServices} != "" ]]; then
+        # shellcheck disable=SC2086
+        ${dockerCompose} pull ${pullServices}
+    fi
+
+    if [[ ${2} == "build" && ${buildServices} != "" ]]; then
+        # shellcheck disable=SC2086
+        ${dockerCompose} build ${buildServices}
+    fi
+
+    if [[ ${2} == "ps" || ${2} == "logs" ]]; then
+        # shellcheck disable=SC2086
+        ${dockerCompose} "${2}" ${servicesString}
+    fi
+}
+
+# Get docker container id
+getContainerId()
+{
+    if [[ $docker == "docker" ]]; then
+        containerId=$(${dockerCompose} ps -q "${1}")
+    else
+        containerId=$(${dockerCompose} ps \
+            | sed '0,/CONTAINER/d' \
+            | awk "/${1}/ {print \$1;exit}")
+    fi
+    echo "${containerId}"
+}
+
+# Reset services
+handleReset()
+{
+    servicesString=$(handleServices "backend frontend mongo redis" "${@:2}")
+    if [[ ${servicesString:0:1} != 1 ]]; then
+        throw "${servicesString:2}\n${YELLOW}Usage: ${1} [backend, frontend, mongo, redis]"
+    fi
+
+    if [[ ${servicesString:2:4} == "all" ]]; then
+        echo -e "${RED}Resetting will remove the ${REDIS_DATA_LOCATION} and ${MONGO_DATA_LOCATION} directories.${NC}"
+        echo -e "${GREEN}Are you sure you want to reset all data? ${YELLOW}[y,n]: ${NC}"
+    else
+        if [[ "${servicesString:2}" == *redis* && "${servicesString:2}" == *mongo* ]]; then
+            echo -e "${RED}Resetting will remove the ${REDIS_DATA_LOCATION} and ${MONGO_DATA_LOCATION} directories.${NC}"
+        elif [[ "${servicesString:2}" == *redis* ]]; then
+            echo -e "${RED}Resetting will remove the ${REDIS_DATA_LOCATION} directory.${NC}"
+        elif [[ "${servicesString:2}" == *mongo* ]]; then
+            echo -e "${RED}Resetting will remove the ${MONGO_DATA_LOCATION} directory.${NC}"
+        fi
+        echo -e "${GREEN}Are you sure you want to reset all data for $(echo "${servicesString:2}" | tr ' ' ',')? ${YELLOW}[y,n]: ${NC}"
+    fi
+
+    read -r confirm
+    if [[ "${confirm}" != y* ]]; then
+        throw "Cancelled reset"
+    fi
+
+    if [[ ${servicesString:2:4} == "all" ]]; then
+        runDockerCommand "$(basename "$0") $1" stop
+        ${dockerCompose} rm -v --force
+        if [[ -d $REDIS_DATA_LOCATION ]]; then
+            rm -rf "${REDIS_DATA_LOCATION}"
+        fi
+        if [[ -d $MONGO_DATA_LOCATION ]]; then
+            rm -rf "${MONGO_DATA_LOCATION}"
+        fi
+    else
+        # shellcheck disable=SC2086
+        runDockerCommand "$(basename "$0") $1" stop ${servicesString:2}
+        # shellcheck disable=SC2086
+        ${dockerCompose} rm -v --force ${servicesString:2}
+        if [[ "${servicesString:2}" == *redis* && -d $REDIS_DATA_LOCATION ]]; then
+            rm -rf "${REDIS_DATA_LOCATION}"
+        fi
+        if [[ "${servicesString:2}" == *mongo* && -d $MONGO_DATA_LOCATION ]]; then
+            rm -rf "${MONGO_DATA_LOCATION}"
+        fi
+    fi
+}
+
+# Attach to service in docker container
+attachContainer()
+{
+    containerId=$(getContainerId "${2}")
+    if [[ -z $containerId ]]; then
+        throw "Error: ${2} offline, please start to attach."
+    fi
+
+    case $2 in
+        backend)
+            echo -e "${YELLOW}Detach with CTRL+P+Q${NC}"
+            ${docker} attach "$containerId"
+            ;;
+
+        mongo)
+            MONGO_VERSION_INT=${MONGO_VERSION:0:1}
+            echo -e "${YELLOW}Detach with CTRL+D${NC}"
+            if [[ $MONGO_VERSION_INT -ge 5 ]]; then
+                ${dockerCompose} exec mongo mongosh musare -u "${MONGO_USER_USERNAME}" -p "${MONGO_USER_PASSWORD}" --eval "disableTelemetry()" --shell
             else
-                servicesString=${servicesString:2}
-                pullArray=()
-                buildArray=()
-                if [[ "${servicesString}" == *mongo* ]]; then
-                    pullArray+=("mongo")
-                fi
-                if [[ "${servicesString}" == *redis* ]]; then
-                    pullArray+=("redis")
-                fi
-                if [[ "${servicesString}" == *backend* ]]; then
-                    buildArray+=("backend")
-                fi
-                if [[ "${servicesString}" == *frontend* ]]; then
-                    buildArray+=("frontend")
-                fi
-                pullServices="${pullArray[*]}"
-                buildServices="${buildArray[*]}"
+                ${dockerCompose} exec mongo mongo musare -u "${MONGO_USER_USERNAME}" -p "${MONGO_USER_PASSWORD}"
             fi
+            ;;
 
-            if [[ ${2} == "stop" || ${2} == "restart" ]]; then
-                # shellcheck disable=SC2086
-                ${dockerCompose} stop ${servicesString}
-            fi
-            if [[ ${2} == "start" || ${2} == "restart" ]]; then
-                # shellcheck disable=SC2086
-                ${dockerCompose} up -d ${servicesString}
-            fi
-            if [[ ${2} == "pull" && ${pullServices} != "" ]]; then
-                # shellcheck disable=SC2086
-                ${dockerCompose} "${2}" ${pullServices}
-            fi
-            if [[ ${2} == "build" && ${buildServices} != "" ]]; then
-                # shellcheck disable=SC2086
-                ${dockerCompose} "${2}" ${buildServices}
-            fi
-            if [[ ${2} == "ps" || ${2} == "logs" ]]; then
-                # shellcheck disable=SC2086
-                ${dockerCompose} "${2}" ${servicesString}
-            fi
+        redis)
+            echo -e "${YELLOW}Detach with CTRL+C${NC}"
+            ${dockerCompose} exec redis redis-cli -a "${REDIS_PASSWORD}"
+            ;;
+
+        *)
+            throw "Invalid service ${2}\n${YELLOW}Usage: ${1} [backend, mongo, redis]"
+            ;;
+    esac
+}
+
+# Lint codebase, docs, scripts, etc
+handleLinting()
+{
+    set +e
+    # shellcheck disable=SC2001
+    services=$(sed "s/\(\ \)\{0,1\}\(-\)\{0,2\}fix//g;t;q1" <<< "${@:2}")
+    fixFound=$?
+    if [[ $fixFound -eq 0 ]]; then
+        fix="--fix"
+        echo -e "${GREEN}Auto-fix enabled${NC}"
+    fi
+    # shellcheck disable=SC2001
+    services=$(sed "s/\(\ \)\{0,1\}\(-\)\{0,2\}no-cache//g;t;q1" <<< "${services}")
+    noCacheFound=$?
+    cache="--cache"
+    if [[ $noCacheFound -eq 0 ]]; then
+        cache=""
+        echo -e "${YELLOW}ESlint cache disabled${NC}"
+    fi
+    set -e
 
-            exitValue=$?
-            if [[ ${exitValue} -gt 0 ]]; then
-                exit ${exitValue}
+    # shellcheck disable=SC2068
+    servicesString=$(handleServices "backend frontend docs shell" ${services[@]})
+    if [[ ${servicesString:0:1} != 1 ]]; then
+        throw "${servicesString:2}\n${YELLOW}Usage: ${1} [backend, frontend, docs, shell] [fix]"
+    fi
+
+    set +e
+    if [[ ${servicesString:2:4} == "all" || "${servicesString:2}" == *frontend* ]]; then
+        echo -e "${CYAN}Running frontend lint...${NC}"
+        ${dockerCompose} exec -T frontend npm run lint -- "${cache}" "${fix}"
+        frontendExitValue=$?
+    fi
+    if [[ ${servicesString:2:4} == "all" || "${servicesString:2}" == *backend* ]]; then
+        echo -e "${CYAN}Running backend lint...${NC}"
+        ${dockerCompose} exec -T backend npm run lint -- "${cache}" "${fix}"
+        backendExitValue=$?
+    fi
+    if [[ ${servicesString:2:4} == "all" || "${servicesString:2}" == *docs* ]]; then
+        echo -e "${CYAN}Running docs lint...${NC}"
+        ${docker} run --rm -v "${scriptLocation}":/workdir ghcr.io/igorshubovych/markdownlint-cli:latest ".wiki" "*.md" "${fix}"
+        docsExitValue=$?
+    fi
+    if [[ ${servicesString:2:4} == "all" || "${servicesString:2}" == *shell* ]]; then
+        echo -e "${CYAN}Running shell lint...${NC}"
+        ${docker} run --rm -v "${scriptLocation}":/mnt koalaman/shellcheck:stable ./*.sh ./**/*.sh
+        shellExitValue=$?
+    fi
+    set -e
+    if [[ ${frontendExitValue} -gt 0 || ${backendExitValue} -gt 0 || ${docsExitValue} -gt 0 || ${shellExitValue} -gt 0 ]]; then
+        exit 1
+    fi
+}
+
+# Validate typescript in services
+handleTypescript()
+{
+    set +e
+    # shellcheck disable=SC2001
+    services=$(sed "s/\(\ \)\{0,1\}\(-\)\{0,2\}strict//g;t;q1" <<< "${@:2}")
+    strictFound=$?
+    if [[ $strictFound -eq 0 ]]; then
+        strict="--strict"
+        echo -e "${GREEN}Strict mode enabled${NC}"
+    fi
+    set -e
+
+    # shellcheck disable=SC2068
+    servicesString=$(handleServices "backend frontend" ${services[@]})
+    if [[ ${servicesString:0:1} != 1 ]]; then
+        throw "${servicesString:2}\n${YELLOW}Usage: ${1} [backend, frontend] [strict]"
+    fi
+
+    set +e
+    if [[ ${servicesString:2:4} == "all" || "${servicesString:2}" == *frontend* ]]; then
+        echo -e "${CYAN}Running frontend typescript check...${NC}"
+        ${dockerCompose} exec -T frontend npm run typescript -- "${strict}"
+        frontendExitValue=$?
+    fi
+    if [[ ${servicesString:2:4} == "all" || "${servicesString:2}" == *backend* ]]; then
+        echo -e "${CYAN}Running backend typescript check...${NC}"
+        ${dockerCompose} exec -T backend npm run typescript -- "${strict}"
+        backendExitValue=$?
+    fi
+    set -e
+    if [[ ${frontendExitValue} -gt 0 || ${backendExitValue} -gt 0 ]]; then
+        exit 1
+    fi
+}
+
+# Execute automated tests in services
+handleTests()
+{
+    # shellcheck disable=SC2068
+    servicesString=$(handleServices "backend frontend" ${services[@]})
+    if [[ ${servicesString:0:1} != 1 ]]; then
+        throw "${servicesString:2}\n${YELLOW}Usage: ${1} [backend, frontend]"
+    fi
+
+    set +e
+    if [[ ${servicesString:2:4} == "all" || "${servicesString:2}" == *backend* ]]; then
+        echo -e "${CYAN}Running backend tests...${NC}"
+        ${dockerCompose} exec -T backend npm run test
+        backendExitValue=$?
+    fi
+    if [[ ${servicesString:2:4} == "all" || "${servicesString:2}" == *frontend* ]]; then
+        echo -e "${CYAN}Running frontend tests...${NC}"
+        ${dockerCompose} exec -T frontend npm run test -- --run
+        frontendExitValue=$?
+    fi
+    set -e
+    if [[ ${backendExitValue} -gt 0 || ${frontendExitValue} -gt 0 ]]; then
+        exit 1
+    fi
+}
+
+# Execute test coverage in services
+handleTestCoverage()
+{
+    servicesString=$(handleServices "frontend" "${@:2}")
+    if [[ ${servicesString:0:1} != 1 ]]; then
+        throw "${servicesString:2}\n${YELLOW}Usage: ${1} [frontend]"
+    fi
+
+    set +e
+    if [[ ${servicesString:2:4} == "all" || "${servicesString:2}" == *frontend* ]]; then
+        echo -e "${CYAN}Running frontend test coverage report...${NC}"
+        ${dockerCompose} exec -T frontend npm run coverage
+        frontendExitValue=$?
+    fi
+    set -e
+    if [[ ${frontendExitValue} -gt 0 ]]; then
+        exit 1
+    fi
+}
+
+# Update Musare
+handleUpdate()
+{
+    musareshModified=$(git diff HEAD -- musare.sh)
+
+    git fetch
+
+    updated=$(git log --name-only --oneline HEAD..@\{u\})
+    if [[ ${updated} == "" ]]; then
+        echo -e "${GREEN}Already up to date${NC}"
+        exit 0
+    fi
+
+    breakingConfigChange=$(git rev-list "$(git rev-parse HEAD)" | grep d8b73be1de231821db34c677110b7b97e413451f)
+    if [[ -f backend/config/default.json && -z $breakingConfigChange ]]; then
+        throw "Configuration has breaking changes. Please rename or remove 'backend/config/default.json' and run the update command again to continue."
+    fi
+
+    musareshChange=$(echo "${updated}" | grep "musare.sh")
+    dbChange=$(echo "${updated}" | grep "backend/logic/db/schemas")
+    bcChange=$(echo "${updated}" | grep "backend/config/default.json")
+    if [[ ( $2 == "auto" && -z $dbChange && -z $bcChange && -z $musareshChange ) || -z $2 ]]; then
+        if [[ -n $musareshChange && $(git diff @\{u\} -- musare.sh) != "" ]]; then
+            if [[ $musareshModified != "" ]]; then
+                throw "musare.sh has been modified, please reset these changes and run the update command again to continue."
+            else
+                git checkout @\{u\} -- musare.sh
+                throw "${YELLOW}musare.sh has been updated, please run the update command again to continue."
             fi
         else
-            echo -e "${RED}${servicesString:2}\n${YELLOW}Usage: ${1} [backend, frontend, mongo, redis]${NC}"
-            exit 1
+            git pull
+            echo -e "${CYAN}Updating...${NC}"
+            runDockerCommand "$(basename "$0") $1" pull
+            runDockerCommand "$(basename "$0") $1" build
+            runDockerCommand "$(basename "$0") $1" restart
+            echo -e "${GREEN}Updated!${NC}"
+            if [[ -n $dbChange ]]; then
+                echo -e "${RED}Database schema has changed, please run migration!${NC}"
+            fi
+            if [[ -n $bcChange ]]; then
+                echo -e "${RED}Backend config has changed, please update!${NC}"
+            fi
         fi
+    elif [[ $2 == "auto" ]]; then
+        throw "Auto Update Failed! musare.sh, database and/or config has changed!"
+    fi
+}
+
+# Backup the database
+handleBackup()
+{
+    if [[ -z "${BACKUP_LOCATION}" ]]; then
+        backupLocation="${scriptLocation%x}/backups"
     else
-        echo -e "${RED}Error: Invalid runDockerCommand input${NC}"
-        exit 1
+        backupLocation="${BACKUP_LOCATION%/}"
+    fi
+    if [[ ! -d "${backupLocation}" ]]; then
+        echo -e "${YELLOW}Creating backup directory at ${backupLocation}${NC}"
+        mkdir "${backupLocation}"
     fi
+    if [[ -z "${BACKUP_NAME}" ]]; then
+        backupLocation="${backupLocation}/musare-$(date +"%Y-%m-%d-%s").dump"
+    else
+        backupLocation="${backupLocation}/${BACKUP_NAME}"
+    fi
+    echo -e "${YELLOW}Creating backup at ${backupLocation}${NC}"
+    ${dockerCompose} exec -T mongo sh -c "mongodump --authenticationDatabase musare -u ${MONGO_USER_USERNAME} -p ${MONGO_USER_PASSWORD} -d musare --archive" > "${backupLocation}"
 }
 
-getContainerId()
+# Restore database from dump
+handleRestore()
 {
-    if [[ ${DOCKER_COMMAND} == "docker" ]]; then
-        containerId=$(${dockerCompose} ps -q "${1}")
+    if [[ -z $2 ]]; then
+        echo -e "${GREEN}Please enter the full path of the dump you wish to restore: ${NC}"
+        read -r restoreFile
     else
-        containerId=$(${dockerCompose} ps | sed '0,/CONTAINER/d' | awk "/${1}/ {print \$1;exit}")
+        restoreFile=$2
+    fi
+
+    if [[ -z ${restoreFile} ]]; then
+        throw "Error: no restore path given, cancelled restoration."
+    elif [[ -d ${restoreFile} ]]; then
+        throw "Error: restore path given is a directory, cancelled restoration."
+    elif [[ ! -f ${restoreFile} ]]; then
+        throw "Error: no file at restore path given, cancelled restoration."
+    else
+        ${dockerCompose} exec -T mongo sh -c "mongorestore --authenticationDatabase musare -u ${MONGO_USER_USERNAME} -p ${MONGO_USER_PASSWORD} --archive" < "${restoreFile}"
     fi
-    echo "${containerId}"
 }
 
+# Toggle user admin role
+handleAdmin()
+{
+    MONGO_VERSION_INT=${MONGO_VERSION:0:1}
+
+    case $2 in
+        add)
+            if [[ -z $3 ]]; then
+                echo -e "${GREEN}Please enter the username of the user you wish to make an admin: ${NC}"
+                read -r adminUser
+            else
+                adminUser=$3
+            fi
+            if [[ -z $adminUser ]]; then
+                throw "Error: Username for new admin not provided."
+            fi
+
+            if [[ $MONGO_VERSION_INT -ge 5 ]]; then
+                ${dockerCompose} exec mongo mongosh musare -u "${MONGO_USER_USERNAME}" -p "${MONGO_USER_PASSWORD}" --eval "disableTelemetry(); db.users.updateOne({username: '${adminUser}'}, {\$set: {role: 'admin'}})"
+            else
+                ${dockerCompose} exec mongo mongo musare -u "${MONGO_USER_USERNAME}" -p "${MONGO_USER_PASSWORD}" --eval "db.users.updateOne({username: '${adminUser}'}, {\$set: {role: 'admin'}})"
+            fi
+            ;;
+        remove)
+            if [[ -z $3 ]]; then
+                echo -e "${GREEN}Please enter the username of the user you wish to remove as admin: ${NC}"
+                read -r adminUser
+            else
+                adminUser=$3
+            fi
+            if [[ -z $adminUser ]]; then
+                throw "Error: Username for new admin not provided."
+            fi
+
+            if [[ $MONGO_VERSION_INT -ge 5 ]]; then
+                ${dockerCompose} exec mongo mongosh musare -u "${MONGO_USER_USERNAME}" -p "${MONGO_USER_PASSWORD}" --eval "disableTelemetry(); db.users.updateOne({username: '${adminUser}'}, {\$set: {role: 'default'}})"
+            else
+                ${dockerCompose} exec mongo mongo musare -u "${MONGO_USER_USERNAME}" -p "${MONGO_USER_PASSWORD}" --eval "db.users.updateOne({username: '${adminUser}'}, {\$set: {role: 'default'}})"
+            fi
+            ;;
+        *)
+            throw "Invalid command $2\n${YELLOW}Usage: ${1} [add,remove] username"
+            ;;
+    esac
+}
+
+availableCommands=$(cat << COMMANDS
+start - Start services
+stop - Stop services
+restart - Restart services
+status - Service status
+logs - View logs for services
+update - Update Musare
+attach [backend,mongo,redis] - Attach to backend service, mongo or redis shell
+build - Build services
+lint - Run lint on frontend, backend, docs and/or shell
+backup - Backup database data to file
+restore - Restore database data from backup file
+reset - Reset service data
+admin [add,remove] - Assign/unassign admin role to/from a user
+typescript - Run typescript checks on frontend and/or backend
+COMMANDS
+)
+
+# Handle command
 case $1 in
     start)
         echo -e "${CYAN}Musare | Start Services${NC}"
@@ -196,277 +601,43 @@ case $1 in
 
     reset)
         echo -e "${CYAN}Musare | Reset Services${NC}"
-        servicesString=$(handleServices "backend frontend mongo redis" "${@:2}")
-        if [[ ${servicesString:0:1} == 1 && ${servicesString:2:4} == "all" ]]; then
-            echo -e "${RED}Resetting will remove the ${REDIS_DATA_LOCATION} and ${MONGO_DATA_LOCATION} directories.${NC}"
-            echo -e "${GREEN}Are you sure you want to reset all data? ${YELLOW}[y,n]: ${NC}"
-            read -r confirm
-            if [[ "${confirm}" == y* ]]; then
-                runDockerCommand "$(basename "$0") $1" stop
-                ${dockerCompose} rm -v --force
-                if [[ -d $REDIS_DATA_LOCATION ]]; then
-                    rm -rf "${REDIS_DATA_LOCATION}"
-                fi
-                if [[ -d $MONGO_DATA_LOCATION ]]; then
-                    rm -rf "${MONGO_DATA_LOCATION}"
-                fi
-            else
-                echo -e "${RED}Cancelled reset${NC}"
-            fi
-        elif [[ ${servicesString:0:1} == 1 ]]; then
-            if [[ "${servicesString:2}" == *redis* && "${servicesString:2}" == *mongo* ]]; then
-                echo -e "${RED}Resetting will remove the ${REDIS_DATA_LOCATION} and ${MONGO_DATA_LOCATION} directories.${NC}"
-            elif [[ "${servicesString:2}" == *redis* ]]; then
-                echo -e "${RED}Resetting will remove the ${REDIS_DATA_LOCATION} directory.${NC}"
-            elif [[ "${servicesString:2}" == *mongo* ]]; then
-                echo -e "${RED}Resetting will remove the ${MONGO_DATA_LOCATION} directory.${NC}"
-            fi
-            echo -e "${GREEN}Are you sure you want to reset all data for $(echo "${servicesString:2}" | tr ' ' ',')? ${YELLOW}[y,n]: ${NC}"
-            read -r confirm
-            if [[ "${confirm}" == y* ]]; then
-                # shellcheck disable=SC2086
-                runDockerCommand "$(basename "$0") $1" stop ${servicesString:2}
-                # shellcheck disable=SC2086
-                ${dockerCompose} rm -v --force ${servicesString}
-                if [[ "${servicesString:2}" == *redis* && -d $REDIS_DATA_LOCATION ]]; then
-                    rm -rf "${REDIS_DATA_LOCATION}"
-                fi
-                if [[ "${servicesString:2}" == *mongo* && -d $MONGO_DATA_LOCATION ]]; then
-                    rm -rf "${MONGO_DATA_LOCATION}"
-                fi
-            else
-                echo -e "${RED}Cancelled reset${NC}"
-            fi
-        else
-            echo -e "${RED}${servicesString:2}\n${YELLOW}Usage: $(basename "$0") build [backend, frontend, mongo, redis]${NC}"
-            exit 1
-        fi
+        # shellcheck disable=SC2068
+        handleReset "$(basename "$0") $1" ${@:2}
         ;;
 
     attach)
         echo -e "${CYAN}Musare | Attach${NC}"
-        if [[ $2 == "backend" ]]; then
-            containerId=$(getContainerId backend)
-            if [[ -z $containerId ]]; then
-                echo -e "${RED}Error: Backend offline, please start to attach.${NC}"
-                exit 1
-            else
-                echo -e "${YELLOW}Detach with CTRL+P+Q${NC}"
-                ${docker} attach "$containerId"
-            fi
-        elif [[ $2 == "mongo" ]]; then
-            MONGO_VERSION_INT=${MONGO_VERSION:0:1}
-            if [[ -z $(getContainerId mongo) ]]; then
-                echo -e "${RED}Error: Mongo offline, please start to attach.${NC}"
-                exit 1
-            else
-                echo -e "${YELLOW}Detach with CTRL+D${NC}"
-                if [[ $MONGO_VERSION_INT -ge 5 ]]; then
-                    ${dockerCompose} exec mongo mongosh musare -u "${MONGO_USER_USERNAME}" -p "${MONGO_USER_PASSWORD}" --eval "disableTelemetry()" --shell
-                else
-                    ${dockerCompose} exec mongo mongo musare -u "${MONGO_USER_USERNAME}" -p "${MONGO_USER_PASSWORD}"
-                fi
-            fi
-        elif [[ $2 == "redis" ]]; then
-            if [[ -z $(getContainerId redis) ]]; then
-                echo -e "${RED}Error: Redis offline, please start to attach.${NC}"
-                exit 1
-            else
-                echo -e "${YELLOW}Detach with CTRL+C${NC}"
-                ${dockerCompose} exec redis redis-cli -a "${REDIS_PASSWORD}"
-            fi
-        else
-            echo -e "${RED}Invalid service $2\n${YELLOW}Usage: $(basename "$0") attach [backend,mongo,redis]${NC}"
-            exit 1
-        fi
+        attachContainer "$(basename "$0") $1" "$2"
         ;;
 
     lint|eslint)
         echo -e "${CYAN}Musare | Lint${NC}"
-        services=$(sed "s/\(\ \)\{0,1\}\(-\)\{0,2\}fix//g;t;q1" <<< "${@:2}")
-        fixFound=$?
-        if [[ $fixFound -eq 0 ]]; then
-            fix="--fix"
-            echo -e "${GREEN}Auto-fix enabled${NC}"
-        fi
-        services=$(sed "s/\(\ \)\{0,1\}\(-\)\{0,2\}no-cache//g;t;q1" <<< "${services}")
-        noCacheFound=$?
-        cache="--cache"
-        if [[ $noCacheFound -eq 0 ]]; then
-            cache=""
-            echo -e "${YELLOW}ESlint cache disabled${NC}"
-        fi
         # shellcheck disable=SC2068
-        servicesString=$(handleServices "backend frontend docs" ${services[@]})
-        if [[ ${servicesString:0:1} == 1 ]]; then
-            if [[ ${servicesString:2:4} == "all" || "${servicesString:2}" == *frontend* ]]; then
-                echo -e "${CYAN}Running frontend lint...${NC}"
-                ${dockerCompose} exec -T frontend npm run lint -- $cache $fix
-                frontendExitValue=$?
-            fi
-            if [[ ${servicesString:2:4} == "all" || "${servicesString:2}" == *backend* ]]; then
-                echo -e "${CYAN}Running backend lint...${NC}"
-                ${dockerCompose} exec -T backend npm run lint -- $cache $fix
-                backendExitValue=$?
-            fi
-            if [[ ${servicesString:2:4} == "all" || "${servicesString:2}" == *docs* ]]; then
-                echo -e "${CYAN}Running docs lint...${NC}"
-                ${docker} run --rm -v "${scriptLocation}":/workdir ghcr.io/igorshubovych/markdownlint-cli:latest ".wiki" "*.md" $fix
-                docsExitValue=$?
-            fi
-            if [[ ${frontendExitValue} -gt 0 || ${backendExitValue} -gt 0 || ${docsExitValue} -gt 0 ]]; then
-                exitValue=1
-            else
-                exitValue=0
-            fi
-        else
-            echo -e "${RED}${servicesString:2}\n${YELLOW}Usage: $(basename "$0") lint [backend, frontend, docs] [fix]${NC}"
-            exitValue=1
-        fi
-        if [[ ${exitValue} -gt 0 ]]; then
-            exit ${exitValue}
-        fi
+        handleLinting "$(basename "$0") $1" ${@:2}
         ;;
 
-
     typescript|ts)
         echo -e "${CYAN}Musare | TypeScript Check${NC}"
-        services=$(sed "s/\(\ \)\{0,1\}\(-\)\{0,2\}strict//g;t;q1" <<< "${@:2}")
-        strictFound=$?
-        if [[ $strictFound -eq 0 ]]; then
-            strict="--strict"
-            echo -e "${GREEN}Strict mode enabled${NC}"
-        fi
         # shellcheck disable=SC2068
-        servicesString=$(handleServices "backend frontend" ${services[@]})
-        if [[ ${servicesString:0:1} == 1 ]]; then
-            if [[ ${servicesString:2:4} == "all" || "${servicesString:2}" == *frontend* ]]; then
-                echo -e "${CYAN}Running frontend typescript check...${NC}"
-                ${dockerCompose} exec -T frontend npm run typescript -- $strict
-                frontendExitValue=$?
-            fi
-            if [[ ${servicesString:2:4} == "all" || "${servicesString:2}" == *backend* ]]; then
-                echo -e "${CYAN}Running backend typescript check...${NC}"
-                ${dockerCompose} exec -T backend npm run typescript -- $strict
-                backendExitValue=$?
-            fi
-            if [[ ${frontendExitValue} -gt 0 || ${backendExitValue} -gt 0 ]]; then
-                exitValue=1
-            else
-                exitValue=0
-            fi
-        else
-            echo -e "${RED}${servicesString:2}\n${YELLOW}Usage: $(basename "$0") typescript [backend, frontend] [strict]${NC}"
-            exitValue=1
-        fi
-        if [[ ${exitValue} -gt 0 ]]; then
-            exit ${exitValue}
-        fi
+        handleTypescript "$(basename "$0") $1" ${@:2}
         ;;
 
     test)
         echo -e "${CYAN}Musare | Test${NC}"
-        servicesString=$(handleServices "backend frontend" "${@:2}")
-        if [[ ${servicesString:0:1} == 1 ]]; then
-            if [[ ${servicesString:2:4} == "all" || "${servicesString:2}" == *backend* ]]; then
-                echo -e "${CYAN}Running backend tests...${NC}"
-                ${dockerCompose} exec -T backend npm run test
-                backendExitValue=$?
-            fi
-            if [[ ${servicesString:2:4} == "all" || "${servicesString:2}" == *frontend* ]]; then
-                echo -e "${CYAN}Running frontend tests...${NC}"
-                ${dockerCompose} exec -T frontend npm run test -- --run
-                frontendExitValue=$?
-            fi
-            if [[ ${backendExitValue} -gt 0 || ${frontendExitValue} -gt 0 ]]; then
-                exitValue=1
-            else
-                exitValue=0
-            fi
-        else
-            echo -e "${RED}${servicesString:2}\n${YELLOW}Usage: $(basename "$0") test [backend, frontend]${NC}"
-            exitValue=1
-        fi
-        if [[ ${exitValue} -gt 0 ]]; then
-            exit ${exitValue}
-        fi
+        # shellcheck disable=SC2068
+        handleTests "$(basename "$0") $1" ${@:2}
         ;;
 
     test:coverage)
         echo -e "${CYAN}Musare | Test Coverage${NC}"
-        servicesString=$(handleServices "frontend" "${@:2}")
-        if [[ ${servicesString:0:1} == 1 ]]; then
-            if [[ ${servicesString:2:4} == "all" || "${servicesString:2}" == *frontend* ]]; then
-                echo -e "${CYAN}Running frontend test coverage report...${NC}"
-                ${dockerCompose} exec -T frontend npm run coverage
-                frontendExitValue=$?
-            fi
-            if [[ ${frontendExitValue} -gt 0 ]]; then
-                exitValue=1
-            else
-                exitValue=0
-            fi
-        else
-            echo -e "${RED}${servicesString:2}\n${YELLOW}Usage: $(basename "$0") test:coverage [frontend]${NC}"
-            exitValue=1
-        fi
-        if [[ ${exitValue} -gt 0 ]]; then
-            exit ${exitValue}
-        fi
+        # shellcheck disable=SC2068
+        handleTestCoverage "$(basename "$0") $1" ${@:2}
         ;;
 
     update)
         echo -e "${CYAN}Musare | Update${NC}"
-        musareshModified=$(git diff HEAD -- musare.sh)
-        git fetch
-        exitValue=$?
-        if [[ ${exitValue} -gt 0 ]]; then
-            exit ${exitValue}
-        fi
-        updated=$(git log --name-only --oneline HEAD..@\{u\})
-        if [[ ${updated} == "" ]]; then
-            echo -e "${GREEN}Already up to date${NC}"
-            exit ${exitValue}
-        fi
-        breakingConfigChange=$(git rev-list "$(git rev-parse HEAD)" | grep d8b73be1de231821db34c677110b7b97e413451f)
-        if [[ -f backend/config/default.json && -z $breakingConfigChange ]]; then
-            echo -e "${RED}Configuration has breaking changes. Please rename or remove 'backend/config/default.json' and run the update command again to continue.${NC}"
-            exit 1
-        fi
-        musareshChange=$(echo "${updated}" | grep "musare.sh")
-        dbChange=$(echo "${updated}" | grep "backend/logic/db/schemas")
-        bcChange=$(echo "${updated}" | grep "backend/config/default.json")
-        if [[ ( $2 == "auto" && -z $dbChange && -z $bcChange && -z $musareshChange ) || -z $2 ]]; then
-            if [[ -n $musareshChange && $(git diff @\{u\} -- musare.sh) != "" ]]; then
-                if [[ $musareshModified != "" ]]; then
-                    echo -e "${RED}musare.sh has been modified, please reset these changes and run the update command again to continue.${NC}"
-                else
-                    git checkout @\{u\} -- musare.sh
-                    echo -e "${YELLOW}musare.sh has been updated, please run the update command again to continue.${NC}"
-                fi
-                exit 1
-            else
-                git pull
-                exitValue=$?
-                if [[ ${exitValue} -gt 0 ]]; then
-                    exit ${exitValue}
-                fi
-                echo -e "${CYAN}Updating...${NC}"
-                runDockerCommand "$(basename "$0") $1" pull
-                runDockerCommand "$(basename "$0") $1" build
-                runDockerCommand "$(basename "$0") $1" restart
-                echo -e "${GREEN}Updated!${NC}"
-                if [[ -n $dbChange ]]; then
-                    echo -e "${RED}Database schema has changed, please run migration!${NC}"
-                fi
-                if [[ -n $bcChange ]]; then
-                    echo -e "${RED}Backend config has changed, please update!${NC}"
-                fi
-            fi
-        elif [[ $2 == "auto" ]]; then
-            echo -e "${RED}Auto Update Failed! musare.sh, database and/or config has changed!${NC}"
-            exit 1
-        fi
+        # shellcheck disable=SC2068
+        handleUpdate "$(basename "$0") $1" ${@:2}
         ;;
 
     logs)
@@ -477,125 +648,32 @@ case $1 in
 
     backup)
         echo -e "${CYAN}Musare | Backup${NC}"
-        if [[ -z "${BACKUP_LOCATION}" ]]; then
-            backupLocation="${scriptLocation%x}/backups"
-        else
-            backupLocation="${BACKUP_LOCATION%/}"
-        fi
-        if [[ ! -d "${backupLocation}" ]]; then
-            echo -e "${YELLOW}Creating backup directory at ${backupLocation}${NC}"
-            mkdir "${backupLocation}"
-        fi
-        if [[ -z "${BACKUP_NAME}" ]]; then
-            backupLocation="${backupLocation}/musare-$(date +"%Y-%m-%d-%s").dump"
-        else
-            backupLocation="${backupLocation}/${BACKUP_NAME}"
-        fi
-        echo -e "${YELLOW}Creating backup at ${backupLocation}${NC}"
-        ${dockerCompose} exec -T mongo sh -c "mongodump --authenticationDatabase musare -u ${MONGO_USER_USERNAME} -p ${MONGO_USER_PASSWORD} -d musare --archive" > "${backupLocation}"
+        # shellcheck disable=SC2068
+        handleBackup "$(basename "$0") $1" ${@:2}
         ;;
 
     restore)
         echo -e "${CYAN}Musare | Restore${NC}"
-        if [[ -z $2 ]]; then
-            echo -e "${GREEN}Please enter the full path of the dump you wish to restore: ${NC}"
-            read -r restoreFile
-        else
-            restoreFile=$2
-        fi
-        if [[ -z ${restoreFile} ]]; then
-            echo -e "${RED}Error: no restore path given, cancelled restoration.${NC}"
-            exit 1
-        elif [[ -d ${restoreFile} ]]; then
-            echo -e "${RED}Error: restore path given is a directory, cancelled restoration.${NC}"
-            exit 1
-        elif [[ ! -f ${restoreFile} ]]; then
-            echo -e "${RED}Error: no file at restore path given, cancelled restoration.${NC}"
-            exit 1
-        else
-            ${dockerCompose} exec -T mongo sh -c "mongorestore --authenticationDatabase musare -u ${MONGO_USER_USERNAME} -p ${MONGO_USER_PASSWORD} --archive" < "${restoreFile}"
-        fi
+        # shellcheck disable=SC2068
+        handleRestore "$(basename "$0") $1" ${@:2}
         ;;
 
     admin)
         echo -e "${CYAN}Musare | Add Admin${NC}"
-        MONGO_VERSION_INT=${MONGO_VERSION:0:1}
-        if [[ $2 == "add" ]]; then
-            if [[ -z $3 ]]; then
-                echo -e "${GREEN}Please enter the username of the user you wish to make an admin: ${NC}"
-                read -r adminUser
-            else
-                adminUser=$3
-            fi
-            if [[ -z $adminUser ]]; then
-                echo -e "${RED}Error: Username for new admin not provided.${NC}"
-                exit 1
-            else
-                if [[ $MONGO_VERSION_INT -ge 5 ]]; then
-                    ${dockerCompose} exec mongo mongosh musare -u "${MONGO_USER_USERNAME}" -p "${MONGO_USER_PASSWORD}" --eval "disableTelemetry(); db.users.updateOne({username: '${adminUser}'}, {\$set: {role: 'admin'}})"
-                else
-                    ${dockerCompose} exec mongo mongo musare -u "${MONGO_USER_USERNAME}" -p "${MONGO_USER_PASSWORD}" --eval "db.users.updateOne({username: '${adminUser}'}, {\$set: {role: 'admin'}})"
-                fi
-            fi
-        elif [[ $2 == "remove" ]]; then
-            if [[ -z $3 ]]; then
-                echo -e "${GREEN}Please enter the username of the user you wish to remove as admin: ${NC}"
-                read -r adminUser
-            else
-                adminUser=$3
-            fi
-            if [[ -z $adminUser ]]; then
-                echo -e "${RED}Error: Username for new admin not provided.${NC}"
-                exit 1
-            else
-                if [[ $MONGO_VERSION_INT -ge 5 ]]; then
-                    ${dockerCompose} exec mongo mongosh musare -u "${MONGO_USER_USERNAME}" -p "${MONGO_USER_PASSWORD}" --eval "disableTelemetry(); db.users.updateOne({username: '${adminUser}'}, {\$set: {role: 'default'}})"
-                else
-                    ${dockerCompose} exec mongo mongo musare -u "${MONGO_USER_USERNAME}" -p "${MONGO_USER_PASSWORD}" --eval "db.users.updateOne({username: '${adminUser}'}, {\$set: {role: 'default'}})"
-                fi
-            fi
-        else
-            echo -e "${RED}Invalid command $2\n${YELLOW}Usage: $(basename "$0") admin [add,remove] username${NC}"
-            exit 1
-        fi
+        # shellcheck disable=SC2068
+        handleAdmin "$(basename "$0") $1" ${@:2}
         ;;
 
     "")
         echo -e "${CYAN}Musare | Available Commands${NC}"
-        echo -e "${YELLOW}start - Start services${NC}"
-        echo -e "${YELLOW}stop - Stop services${NC}"
-        echo -e "${YELLOW}restart - Restart services${NC}"
-        echo -e "${YELLOW}status - Service status${NC}"
-        echo -e "${YELLOW}logs - View logs for services${NC}"
-        echo -e "${YELLOW}update - Update Musare${NC}"
-        echo -e "${YELLOW}attach [backend,mongo,redis] - Attach to backend service, mongo or redis shell${NC}"
-        echo -e "${YELLOW}build - Build services${NC}"
-        echo -e "${YELLOW}lint - Run lint on frontend, backend and/or docs${NC}"
-        echo -e "${YELLOW}backup - Backup database data to file${NC}"
-        echo -e "${YELLOW}restore - Restore database data from backup file${NC}"
-        echo -e "${YELLOW}reset - Reset service data${NC}"
-        echo -e "${YELLOW}admin [add,remove] - Assign/unassign admin role to/from a user${NC}"
-        echo -e "${YELLOW}typescript - Run typescript checks on frontend and/or backend${NC}"
+        echo -e "${YELLOW}${availableCommands}${NC}"
         ;;
 
     *)
         echo -e "${CYAN}Musare${NC}"
         echo -e "${RED}Error: Invalid Command $1${NC}"
         echo -e "${CYAN}Available Commands:${NC}"
-        echo -e "${YELLOW}start - Start services${NC}"
-        echo -e "${YELLOW}stop - Stop services${NC}"
-        echo -e "${YELLOW}restart - Restart services${NC}"
-        echo -e "${YELLOW}status - Service status${NC}"
-        echo -e "${YELLOW}logs - View logs for services${NC}"
-        echo -e "${YELLOW}update - Update Musare${NC}"
-        echo -e "${YELLOW}attach [backend,mongo,redis] - Attach to backend service, mongo or redis shell${NC}"
-        echo -e "${YELLOW}build - Build services${NC}"
-        echo -e "${YELLOW}lint - Run lint on frontend, backend and/or docs${NC}"
-        echo -e "${YELLOW}backup - Backup database data to file${NC}"
-        echo -e "${YELLOW}restore - Restore database data from backup file${NC}"
-        echo -e "${YELLOW}reset - Reset service data${NC}"
-        echo -e "${YELLOW}admin [add,remove] - Assign/unassign admin role to/from a user${NC}"
-        echo -e "${YELLOW}typescript - Run typescript checks on frontend and/or backend${NC}"
+        echo -e "${YELLOW}${availableCommands}${NC}"
         exit 1
         ;;
 esac