Browse Source

refactor: Use classes for events

Owen Diffey 1 month ago
parent
commit
5e845faa98
36 changed files with 540 additions and 151 deletions
  1. 61 0
      backend/src/BaseModule.ts
  2. 23 12
      backend/src/Job.ts
  3. 7 0
      backend/src/ModuleManager.ts
  4. 14 10
      backend/src/main.ts
  5. 41 9
      backend/src/modules/DataModule.ts
  6. 20 0
      backend/src/modules/DataModule/DataModuleEvent.ts
  7. 5 0
      backend/src/modules/DataModule/ModelCreatedEvent.ts
  8. 5 0
      backend/src/modules/DataModule/ModelDeletedEvent.ts
  9. 5 0
      backend/src/modules/DataModule/ModelUpdatedEvent.ts
  10. 5 0
      backend/src/modules/DataModule/models/minifiedUsers/events/MinifiedUserCreatedEvent.ts
  11. 5 0
      backend/src/modules/DataModule/models/minifiedUsers/events/MinifiedUserDeletedEvent.ts
  12. 5 0
      backend/src/modules/DataModule/models/minifiedUsers/events/MinifiedUserUpdatedEvent.ts
  13. 5 0
      backend/src/modules/DataModule/models/news/events/NewsCreatedEvent.ts
  14. 5 0
      backend/src/modules/DataModule/models/news/events/NewsDeletedEvent.ts
  15. 5 0
      backend/src/modules/DataModule/models/news/events/NewsUpdatedEvent.ts
  16. 5 0
      backend/src/modules/DataModule/models/sessions/events/SessionCreatedEvent.ts
  17. 5 0
      backend/src/modules/DataModule/models/sessions/events/SessionDeletedEvent.ts
  18. 5 0
      backend/src/modules/DataModule/models/sessions/events/SessionUpdatedEvent.ts
  19. 5 0
      backend/src/modules/DataModule/models/stations/events/StationCreatedEvent.ts
  20. 5 0
      backend/src/modules/DataModule/models/stations/events/StationDeletedEvent.ts
  21. 5 0
      backend/src/modules/DataModule/models/stations/events/StationUpdatedEvent.ts
  22. 6 2
      backend/src/modules/DataModule/models/users/config.ts
  23. 5 0
      backend/src/modules/DataModule/models/users/events/UserCreatedEvent.ts
  24. 5 0
      backend/src/modules/DataModule/models/users/events/UserDeletedEvent.ts
  25. 5 0
      backend/src/modules/DataModule/models/users/events/UserUpdatedEvent.ts
  26. 59 56
      backend/src/modules/EventsModule.ts
  27. 124 0
      backend/src/modules/EventsModule/Event.ts
  28. 10 0
      backend/src/modules/EventsModule/ModuleEvent.ts
  29. 8 0
      backend/src/modules/EventsModule/events/JobCompletedEvent.ts
  30. 14 20
      backend/src/modules/EventsModule/jobs/Subscribe.ts
  31. 22 18
      backend/src/modules/EventsModule/jobs/SubscribeMany.ts
  32. 10 14
      backend/src/modules/WebSocketModule.ts
  33. 7 0
      backend/src/types/EventDerived.ts
  34. 14 0
      backend/src/utils/assertEventDerived.ts
  35. 2 2
      frontend/src/components/AdvancedTable.vue
  36. 8 8
      frontend/src/stores/model.ts

+ 61 - 0
backend/src/BaseModule.ts

@@ -4,6 +4,7 @@ import { forEachIn } from "@common/utils/forEachIn";
 import LogBook, { Log } from "@/LogBook";
 import ModuleManager from "@/ModuleManager";
 import Job from "./Job";
+import Event from "./modules/EventsModule/Event";
 
 export enum ModuleStatus {
 	LOADED = "LOADED",
@@ -24,6 +25,8 @@ export default abstract class BaseModule {
 
 	protected _jobs: Record<string, typeof Job>;
 
+	protected _events: Record<string, typeof Event>;
+
 	/**
 	 * Base Module
 	 *
@@ -34,6 +37,7 @@ export default abstract class BaseModule {
 		this._status = ModuleStatus.LOADED;
 		this._dependentModules = [];
 		this._jobs = {};
+		this._events = {};
 		this.log(`Module (${this._name}) loaded`);
 	}
 
@@ -140,6 +144,62 @@ export default abstract class BaseModule {
 		);
 	}
 
+	/**
+	 * _loadEvents - Load events
+	 */
+	private async _loadEvents() {
+		let events;
+
+		try {
+			events = await readdir(
+				path.resolve(
+					__dirname,
+					`./modules/${this.constructor.name}/events`
+				)
+			);
+		} catch (error) {
+			if (
+				error instanceof Error &&
+				"code" in error &&
+				error.code === "ENOENT"
+			)
+				return;
+
+			throw error;
+		}
+
+		await forEachIn(events, async eventFile => {
+			const { default: EventClass } = await import(
+				`./modules/${this.constructor.name}/events/${eventFile}`
+			);
+
+			const eventName = EventClass.getName();
+
+			this._events[eventName] = EventClass;
+		});
+	}
+
+	/**
+	 * getEvent - Get module event
+	 */
+	public getEvent(name: string) {
+		const [, Event] =
+			Object.entries(this._events).find(
+				([eventName]) => eventName === name
+			) ?? [];
+
+		if (!Event) throw new Error(`Event "${name}" not found.`);
+
+		return Event;
+	}
+
+	/**
+	 * getEvents - Get module events
+	 */
+	public getEvents() {
+		return this._events;
+	}
+
 	/**
 	 * startup - Startup module
 	 */
@@ -153,6 +213,7 @@ export default abstract class BaseModule {
 	 */
 	protected async _started() {
 		await this._loadJobs();
+		await this._loadEvents();
 		this.log(`Module (${this._name}) started`);
 		this.setStatus(ModuleStatus.STARTED);
 	}

+ 23 - 12
backend/src/Job.ts

@@ -6,6 +6,7 @@ import JobStatistics, { JobStatisticsType } from "@/JobStatistics";
 import LogBook, { Log } from "@/LogBook";
 import BaseModule from "./BaseModule";
 import EventsModule from "./modules/EventsModule";
+import JobCompletedEvent from "./modules/EventsModule/events/JobCompletedEvent";
 
 export enum JobStatus {
 	QUEUED = "QUEUED",
@@ -208,12 +209,17 @@ export default abstract class Job {
 			const callbackRef = this._context.getCallbackRef();
 
 			if (callbackRef) {
-				await EventsModule.publish(`job.${this.getUuid()}`, {
-					socketId,
-					callbackRef,
-					status: "success",
-					data
-				});
+				await EventsModule.publish(
+					new JobCompletedEvent(
+						{
+							socketId,
+							callbackRef,
+							status: "success",
+							data
+						},
+						this.getUuid()
+					)
+				);
 			}
 
 			this.log({
@@ -234,12 +240,17 @@ export default abstract class Job {
 			const callbackRef = this._context.getCallbackRef();
 
 			if (callbackRef) {
-				await EventsModule.publish(`job.${this.getUuid()}`, {
-					socketId,
-					callbackRef,
-					status: "error",
-					message
-				});
+				await EventsModule.publish(
+					new JobCompletedEvent(
+						{
+							socketId,
+							callbackRef,
+							status: "error",
+							message
+						},
+						this.getUuid()
+					)
+				);
 			}
 
 			this.log({

+ 7 - 0
backend/src/ModuleManager.ts

@@ -29,6 +29,13 @@ export class ModuleManager {
 			| undefined;
 	}
 
+	/**
+	 * Gets modules
+	 */
+	public getModules() {
+		return this._modules;
+	}
+
 	/**
 	 * loadModule - Load and initialize module
 	 *

+ 14 - 10
backend/src/main.ts

@@ -57,18 +57,18 @@ ModuleManager.startup().then(async () => {
 	// });
 
 	// Events schedule (was notifications)
-	const now = Date.now();
-	EventsModule.schedule("test", 30000);
+	// const now = Date.now();
+	// EventsModule.schedule("test", 30000);
 
-	await EventsModule.subscribe("schedule", "test", async () => {
-		console.log(`SCHEDULED: ${now} :: ${Date.now()}`);
-	});
+	// await EventsModule.subscribe("schedule", "test", async () => {
+	// 	console.log(`SCHEDULED: ${now} :: ${Date.now()}`);
+	// });
 
-	// Events (was cache pub/sub)
-	await EventsModule.subscribe("event", "test", async value => {
-		console.log(`PUBLISHED: ${value}`);
-	});
-	await EventsModule.publish("test", "a value!");
+	// // Events (was cache pub/sub)
+	// await EventsModule.subscribe("event", "test", async value => {
+	// 	console.log(`PUBLISHED: ${value}`);
+	// });
+	// await EventsModule.publish("test", "a value!");
 });
 
 // TOOD remove, or put behind debug option
@@ -203,6 +203,10 @@ const runCommand = (line: string) => {
 			console.log(ModuleManager.getJobs());
 			break;
 		}
+		case "getevents": {
+			console.log(EventsModule.getAllEvents());
+			break;
+		}
 		default: {
 			if (!/^\s*$/.test(command))
 				console.log(`Command "${command}" not found`);

+ 41 - 9
backend/src/modules/DataModule.ts

@@ -45,6 +45,8 @@ export class DataModule extends BaseModule {
 
 		await this._loadModelJobs();
 
+		await this._loadModelEvents();
+
 		await super._started();
 	}
 
@@ -111,15 +113,11 @@ export class DataModule extends BaseModule {
 					if (!modelId && action !== "created")
 						throw new Error(`Model Id not found for "${event}"`);
 
-					const channel = `model.${modelName}.${action}`;
-
-					await EventsModule.publish(channel, { doc, oldDoc });
+					const EventClass = this.getEvent(`${modelName}.${action}`);
 
-					if (action !== "created")
-						await EventsModule.publish(`${channel}.${modelId}`, {
-							doc,
-							oldDoc
-						});
+					await EventsModule.publish(
+						new EventClass({ doc, oldDoc }, modelId)
+					);
 				});
 			});
 	}
@@ -139,7 +137,7 @@ export class DataModule extends BaseModule {
 		await forEachIn(
 			Object.entries(eventListeners),
 			async ([event, callback]) =>
-				EventsModule.subscribe("event", event, callback)
+				EventsModule.pSubscribe(event, callback)
 		);
 	}
 
@@ -371,6 +369,40 @@ export class DataModule extends BaseModule {
 			});
 		});
 	}
+
+	private async _loadModelEvents() {
+		if (!this._models) throw new Error("Models not loaded");
+
+		await forEachIn(Object.keys(this._models), async modelName => {
+			let events;
+
+			try {
+				events = await readdir(
+					path.resolve(
+						__dirname,
+						`./${this.constructor.name}/models/${modelName}/events/`
+					)
+				);
+			} catch (error) {
+				if (
+					error instanceof Error &&
+					"code" in error &&
+					error.code === "ENOENT"
+				)
+					return;
+
+				throw error;
+			}
+
+			await forEachIn(events, async eventFile => {
+				const { default: EventClass } = await import(
+					`./${this.constructor.name}/models/${modelName}/events/${eventFile}`
+				);
+
+				this._events[EventClass.getName()] = EventClass;
+			});
+		});
+	}
 }
 
 export default new DataModule();

+ 20 - 0
backend/src/modules/DataModule/DataModuleEvent.ts

@@ -0,0 +1,20 @@
+import DataModule from "../DataModule";
+import ModuleEvent from "../EventsModule/ModuleEvent";
+
+export default abstract class DataModuleEvent extends ModuleEvent {
+	protected static _module = DataModule;
+
+	protected static _modelName: string;
+
+	public static override getName() {
+		return `${this._modelName}.${super.getName()}`;
+	}
+
+	public static getModelName() {
+		return this._modelName;
+	}
+
+	public getModelName() {
+		return (this.constructor as typeof DataModuleEvent).getModelName();
+	}
+}

+ 5 - 0
backend/src/modules/DataModule/ModelCreatedEvent.ts

@@ -0,0 +1,5 @@
+import DataModuleEvent from "./DataModuleEvent";
+
+export default abstract class ModelCreatedEvent extends DataModuleEvent {
+	protected static _name = "created";
+}

+ 5 - 0
backend/src/modules/DataModule/ModelDeletedEvent.ts

@@ -0,0 +1,5 @@
+import DataModuleEvent from "./DataModuleEvent";
+
+export default abstract class ModelDeletedEvent extends DataModuleEvent {
+	protected static _name = "deleted";
+}

+ 5 - 0
backend/src/modules/DataModule/ModelUpdatedEvent.ts

@@ -0,0 +1,5 @@
+import DataModuleEvent from "./DataModuleEvent";
+
+export default abstract class ModelUpdatedEvent extends DataModuleEvent {
+	protected static _name = "updated";
+}

+ 5 - 0
backend/src/modules/DataModule/models/minifiedUsers/events/MinifiedUserCreatedEvent.ts

@@ -0,0 +1,5 @@
+import ModelCreatedEvent from "@/modules/DataModule/ModelCreatedEvent";
+
+export default abstract class MinifiedUserCreatedEvent extends ModelCreatedEvent {
+	protected static _modelName = "minifiedUsers";
+}

+ 5 - 0
backend/src/modules/DataModule/models/minifiedUsers/events/MinifiedUserDeletedEvent.ts

@@ -0,0 +1,5 @@
+import ModelDeletedEvent from "@/modules/DataModule/ModelDeletedEvent";
+
+export default abstract class MinifiedUserDeletedEvent extends ModelDeletedEvent {
+	protected static _modelName = "minifiedUsers";
+}

+ 5 - 0
backend/src/modules/DataModule/models/minifiedUsers/events/MinifiedUserUpdatedEvent.ts

@@ -0,0 +1,5 @@
+import ModelUpdatedEvent from "@/modules/DataModule/ModelUpdatedEvent";
+
+export default abstract class MinifiedUserUpdatedEvent extends ModelUpdatedEvent {
+	protected static _modelName = "minifiedUsers";
+}

+ 5 - 0
backend/src/modules/DataModule/models/news/events/NewsCreatedEvent.ts

@@ -0,0 +1,5 @@
+import ModelCreatedEvent from "@/modules/DataModule/ModelCreatedEvent";
+
+export default abstract class NewsCreatedEvent extends ModelCreatedEvent {
+	protected static _modelName = "news";
+}

+ 5 - 0
backend/src/modules/DataModule/models/news/events/NewsDeletedEvent.ts

@@ -0,0 +1,5 @@
+import ModelDeletedEvent from "@/modules/DataModule/ModelDeletedEvent";
+
+export default abstract class NewsDeletedEvent extends ModelDeletedEvent {
+	protected static _modelName = "news";
+}

+ 5 - 0
backend/src/modules/DataModule/models/news/events/NewsUpdatedEvent.ts

@@ -0,0 +1,5 @@
+import ModelUpdatedEvent from "@/modules/DataModule/ModelUpdatedEvent";
+
+export default abstract class NewsUpdatedEvent extends ModelUpdatedEvent {
+	protected static _modelName = "news";
+}

+ 5 - 0
backend/src/modules/DataModule/models/sessions/events/SessionCreatedEvent.ts

@@ -0,0 +1,5 @@
+import ModelCreatedEvent from "@/modules/DataModule/ModelCreatedEvent";
+
+export default abstract class SessionCreatedEvent extends ModelCreatedEvent {
+	protected static _modelName = "sessions";
+}

+ 5 - 0
backend/src/modules/DataModule/models/sessions/events/SessionDeletedEvent.ts

@@ -0,0 +1,5 @@
+import ModelDeletedEvent from "@/modules/DataModule/ModelDeletedEvent";
+
+export default abstract class SessionDeletedEvent extends ModelDeletedEvent {
+	protected static _modelName = "sessions";
+}

+ 5 - 0
backend/src/modules/DataModule/models/sessions/events/SessionUpdatedEvent.ts

@@ -0,0 +1,5 @@
+import ModelUpdatedEvent from "@/modules/DataModule/ModelUpdatedEvent";
+
+export default abstract class SessionUpdatedEvent extends ModelUpdatedEvent {
+	protected static _modelName = "sessions";
+}

+ 5 - 0
backend/src/modules/DataModule/models/stations/events/StationCreatedEvent.ts

@@ -0,0 +1,5 @@
+import ModelCreatedEvent from "@/modules/DataModule/ModelCreatedEvent";
+
+export default abstract class StationCreatedEvent extends ModelCreatedEvent {
+	protected static _modelName = "stations";
+}

+ 5 - 0
backend/src/modules/DataModule/models/stations/events/StationDeletedEvent.ts

@@ -0,0 +1,5 @@
+import ModelDeletedEvent from "@/modules/DataModule/ModelDeletedEvent";
+
+export default abstract class StationDeletedEvent extends ModelDeletedEvent {
+	protected static _modelName = "stations";
+}

+ 5 - 0
backend/src/modules/DataModule/models/stations/events/StationUpdatedEvent.ts

@@ -0,0 +1,5 @@
+import ModelUpdatedEvent from "@/modules/DataModule/ModelUpdatedEvent";
+
+export default abstract class StationUpdatedEvent extends ModelUpdatedEvent {
+	protected static _modelName = "stations";
+}

+ 6 - 2
backend/src/modules/DataModule/models/users/config.ts

@@ -2,17 +2,21 @@ import { SchemaOptions } from "mongoose";
 import CacheModule from "@/modules/CacheModule";
 import getData from "./getData";
 import { UserSchema } from "./schema";
+import ModelUpdatedEvent from "../../ModelUpdatedEvent";
+import ModelDeletedEvent from "../../ModelDeletedEvent";
 
 export default {
 	documentVersion: 4,
 	eventListeners: {
-		"model.users.updated": async doc => {
+		"data.users.updated.*": async (event: ModelUpdatedEvent) => {
+			const { doc } = event.getData();
 			CacheModule.removeMany([
 				`user-permissions.${doc._id}`,
 				`model-permissions.*.user.${doc._id}`
 			]);
 		},
-		"model.users.deleted": async oldDoc => {
+		"data.users.deleted.*": async (event: ModelDeletedEvent) => {
+			const { oldDoc } = event.getData();
 			CacheModule.removeMany([
 				`user-permissions.${oldDoc._id}`,
 				`model-permissions.*.user.${oldDoc._id}`

+ 5 - 0
backend/src/modules/DataModule/models/users/events/UserCreatedEvent.ts

@@ -0,0 +1,5 @@
+import ModelCreatedEvent from "@/modules/DataModule/ModelCreatedEvent";
+
+export default abstract class UserCreatedEvent extends ModelCreatedEvent {
+	protected static _modelName = "users";
+}

+ 5 - 0
backend/src/modules/DataModule/models/users/events/UserDeletedEvent.ts

@@ -0,0 +1,5 @@
+import ModelDeletedEvent from "@/modules/DataModule/ModelDeletedEvent";
+
+export default abstract class UserDeletedEvent extends ModelDeletedEvent {
+	protected static _modelName = "users";
+}

+ 5 - 0
backend/src/modules/DataModule/models/users/events/UserUpdatedEvent.ts

@@ -0,0 +1,5 @@
+import ModelUpdatedEvent from "@/modules/DataModule/ModelUpdatedEvent";
+
+export default abstract class UserUpdatedEvent extends ModelUpdatedEvent {
+	protected static _modelName = "users";
+}

+ 59 - 56
backend/src/modules/EventsModule.ts

@@ -11,6 +11,10 @@ import config from "config";
 import { forEachIn } from "@common/utils/forEachIn";
 import BaseModule, { ModuleStatus } from "@/BaseModule";
 import WebSocketModule from "./WebSocketModule";
+import Event from "@/modules/EventsModule/Event";
+import { EventDerived } from "@/types/EventDerived";
+import assertEventDerived from "@/utils/assertEventDerived";
+import ModuleManager from "@/ModuleManager";
 
 export class EventsModule extends BaseModule {
 	private _pubClient?: RedisClientType<
@@ -29,7 +33,7 @@ export class EventsModule extends BaseModule {
 
 	private _pSubscriptions: Record<
 		string,
-		((message: any) => Promise<void>)[]
+		((message: string, key: string) => Promise<void>)[]
 	>;
 
 	private _socketSubscriptions: Record<string, Set<string>>;
@@ -148,7 +152,11 @@ export class EventsModule extends BaseModule {
 
 		const { database = 0 } = this._subClient.options ?? {};
 
-		await this._subClient.PSUBSCRIBE(
+		await this._subClient.pSubscribe("event.*", async (...args) =>
+			this._subscriptionListener(...args)
+		);
+
+		await this._subClient.pSubscribe(
 			`__keyevent@${database}__:expired`,
 			async message => {
 				const type = message.substring(0, message.indexOf("."));
@@ -166,6 +174,26 @@ export class EventsModule extends BaseModule {
 		);
 	}
 
+	public getEvent(path: string) {
+		const moduleName = path.substring(0, path.indexOf("."));
+		const eventName = path.substring(path.indexOf(".") + 1);
+
+		if (moduleName === this._name) return super.getEvent(eventName);
+
+		const module = ModuleManager.getModule(moduleName);
+		if (!module) throw new Error(`Module "${moduleName}" not found`);
+
+		return module.getEvent(eventName);
+	}
+
+	public getAllEvents() {
+		return Object.fromEntries(
+			Object.entries(ModuleManager.getModules() ?? {}).map(
+				([name, module]) => [name, Object.keys(module.getEvents())]
+			)
+		);
+	}
+
 	/**
 	 * createKey - Create hex key
 	 */
@@ -182,13 +210,11 @@ export class EventsModule extends BaseModule {
 	/**
 	 * publish - Publish an event
 	 */
-	public async publish(channel: string, value: any) {
+	public async publish(event: typeof Event) {
 		if (!this._pubClient) throw new Error("Redis pubClient unavailable.");
 
-		if (!value) throw new Error("Invalid value");
-
-		if (["object", "array"].includes(typeof value))
-			value = JSON.stringify(value);
+		const channel = event.getKey();
+		const value = event.makeMessage();
 
 		await this._pubClient.publish(this._createKey("event", channel), value);
 	}
@@ -201,37 +227,29 @@ export class EventsModule extends BaseModule {
 
 		if (type !== "event") return;
 
-		const channel = key.substring(key.indexOf(".") + 1);
+		key = key.substring(key.indexOf(".") + 1);
 
-		if (message.startsWith("[") || message.startsWith("{"))
-			try {
-				message = JSON.parse(message);
-			} catch (err) {
-				console.error(err);
-			}
-		else if (message.startsWith('"') && message.endsWith('"'))
-			message = message.substring(1).substring(0, message.length - 2);
+		const { path, scope } = Event.parseKey(key);
+		const EventClass = this.getEvent(path);
+		const parsedMessage = EventClass.parseMessage(message);
+		const event = new EventClass(parsedMessage, scope);
 
-		if (this._subscriptions && this._subscriptions[channel])
-			await forEachIn(this._subscriptions[channel], async cb =>
-				cb(message)
-			);
+		if (this._subscriptions && this._subscriptions[key])
+			await forEachIn(this._subscriptions[key], async cb => cb(event));
 
 		if (this._pSubscriptions)
 			await forEachIn(
 				Object.entries(this._pSubscriptions).filter(([subscription]) =>
-					new RegExp(subscription).test(channel)
+					new RegExp(subscription).test(key)
 				),
 				async ([, callbacks]) =>
-					forEachIn(callbacks, async cb => cb(message))
+					forEachIn(callbacks, async cb => cb(event))
 			);
 
-		if (!this._socketSubscriptions[channel]) return;
+		if (!this._socketSubscriptions[key]) return;
 
-		for await (const socketId of this._socketSubscriptions[
-			channel
-		].values()) {
-			await WebSocketModule.dispatch(socketId, channel, message);
+		for await (const socketId of this._socketSubscriptions[key].values()) {
+			await WebSocketModule.dispatch(socketId, key, event.getData());
 		}
 	}
 
@@ -239,31 +257,27 @@ export class EventsModule extends BaseModule {
 	 * subscribe - Subscribe to an event or schedule completion
 	 */
 	public async subscribe(
-		type: "event" | "schedule",
-		channel: string,
-		callback: (message?: any) => Promise<void>
+		EventClass: any,
+		callback: (message?: any) => Promise<void>,
+		scope?: string
 	) {
 		if (!this._subClient) throw new Error("Redis subClient unavailable.");
 
+		const key = EventClass.getKey(scope);
+		const type = EventClass.getType();
+
 		if (type === "schedule") {
-			if (!this._scheduleCallbacks[channel])
-				this._scheduleCallbacks[channel] = [];
+			if (!this._scheduleCallbacks[key])
+				this._scheduleCallbacks[key] = [];
 
-			this._scheduleCallbacks[channel].push(() => callback());
+			this._scheduleCallbacks[key].push(() => callback());
 
 			return;
 		}
 
-		if (!this._subscriptions[channel]) {
-			this._subscriptions[channel] = [];
-
-			await this._subClient.subscribe(
-				this._createKey(type, channel),
-				(...args) => this._subscriptionListener(...args)
-			);
-		}
+		if (!this._subscriptions[key]) this._subscriptions[key] = [];
 
-		this._subscriptions[channel].push(callback);
+		this._subscriptions[key].push(callback);
 	}
 
 	/**
@@ -271,18 +285,11 @@ export class EventsModule extends BaseModule {
 	 */
 	public async pSubscribe(
 		pattern: string,
-		callback: (message?: any) => Promise<void>
+		callback: (message: string, key: string) => Promise<void>
 	) {
 		if (!this._subClient) throw new Error("Redis subClient unavailable.");
 
-		if (!this._pSubscriptions[pattern]) {
-			this._pSubscriptions[pattern] = [];
-
-			await this._subClient.pSubscribe(
-				this._createKey("event", pattern),
-				(...args) => this._subscriptionListener(...args)
-			);
-		}
+		if (!this._pSubscriptions[pattern]) this._pSubscriptions[pattern] = [];
 
 		this._pSubscriptions[pattern].push(callback);
 	}
@@ -319,10 +326,8 @@ export class EventsModule extends BaseModule {
 
 		this._subscriptions[channel].splice(index, 1);
 
-		if (this._subscriptions[channel].length === 0) {
+		if (this._subscriptions[channel].length === 0)
 			delete this._subscriptions[channel];
-			await this._subClient.unsubscribe(this._createKey(type, channel)); // TODO: Provide callback when unsubscribing
-		}
 	}
 
 	/**
@@ -354,8 +359,6 @@ export class EventsModule extends BaseModule {
 
 	public async subscribeSocket(channel: string, socketId: string) {
 		if (!this._socketSubscriptions[channel]) {
-			await this.subscribe("event", channel, () => Promise.resolve());
-
 			this._socketSubscriptions[channel] = new Set();
 		}
 

+ 124 - 0
backend/src/modules/EventsModule/Event.ts

@@ -0,0 +1,124 @@
+import { HydratedDocument, Model } from "mongoose";
+import { UserSchema } from "@models/users/schema";
+
+export default abstract class Event {
+	protected static _namespace: string;
+
+	protected static _name: string;
+
+	protected static _type: "event" | "schedule" = "event";
+
+	protected static _hasPermission:
+		| boolean
+		| CallableFunction
+		| (boolean | CallableFunction)[] = false;
+
+	protected _data: any;
+
+	protected _scope?: string;
+
+	public constructor(data: any, scope?: string) {
+		this._data = data;
+		this._scope = scope;
+	}
+
+	public static getNamespace() {
+		return this._namespace;
+	}
+
+	public static getName() {
+		return this._name;
+	}
+
+	public static getPath() {
+		return `${this.getNamespace()}.${this.getName()}`;
+	}
+
+	public static getKey(scope?: string) {
+		const path = this.getPath();
+
+		if (scope) return `${path}:${scope}`;
+
+		return path;
+	}
+
+	public static parseKey(key: string) {
+		const [path, scope] = key.split(":");
+
+		return {
+			path,
+			scope
+		};
+	}
+
+	public static getType() {
+		return this._type;
+	}
+
+	public static async hasPermission(
+		user: HydratedDocument<UserSchema> | null,
+		scope?: string
+	) {
+		const options = Array.isArray(this._hasPermission)
+			? this._hasPermission
+			: [this._hasPermission];
+
+		return options.reduce(async (previous, option) => {
+			if (await previous) return true;
+
+			if (typeof option === "boolean") return option;
+
+			if (typeof option === "function") return option(user, scope);
+
+			return false;
+		}, Promise.resolve(false));
+	}
+
+	public static makeMessage(data: any) {
+		if (["object", "array"].includes(typeof data))
+			return JSON.stringify(data);
+
+		return data;
+	}
+
+	public static parseMessage(message: string) {
+		let parsedMessage = message;
+
+		if (parsedMessage.startsWith("[") || parsedMessage.startsWith("{"))
+			try {
+				parsedMessage = JSON.parse(parsedMessage);
+			} catch (err) {
+				console.error(err);
+			}
+		else if (parsedMessage.startsWith('"') && parsedMessage.endsWith('"'))
+			parsedMessage = parsedMessage
+				.substring(1)
+				.substring(0, parsedMessage.length - 2);
+
+		return parsedMessage;
+	}
+
+	public getNamespace() {
+		return (this.constructor as typeof Event).getNamespace();
+	}
+
+	public getName() {
+		return (this.constructor as typeof Event).getName();
+	}
+
+	public getPath() {
+		return (this.constructor as typeof Event).getPath();
+	}
+
+	public getKey() {
+		return (this.constructor as typeof Event).getKey(this._scope);
+	}
+
+	public getData() {
+		return this._data;
+	}
+
+	public makeMessage() {
+		return (this.constructor as typeof Event).makeMessage(this._data);
+	}
+}

+ 10 - 0
backend/src/modules/EventsModule/ModuleEvent.ts

@@ -0,0 +1,10 @@
+import BaseModule from "@/BaseModule";
+import Event from "./Event";
+
+export default abstract class ModuleEvent extends Event {
+	protected static _module: InstanceType<typeof BaseModule>;
+
+	public static getNamespace() {
+		return this._module.getName();
+	}
+}

+ 8 - 0
backend/src/modules/EventsModule/events/JobCompletedEvent.ts

@@ -0,0 +1,8 @@
+import EventsModule from "@/modules/EventsModule";
+import ModuleEvent from "../ModuleEvent";
+
+export default class JobCompletedEvent extends ModuleEvent {
+	protected static _module = EventsModule;
+
+	protected static _name = "job.completed";
+}

+ 14 - 20
backend/src/modules/EventsModule/jobs/Subscribe.ts

@@ -2,6 +2,8 @@ import Job, { JobOptions } from "@/Job";
 import EventsModule from "@/modules/EventsModule";
 
 export default class Subscribe extends Job {
+	protected static _hasPermission = true;
+
 	public constructor(payload?: unknown, options?: JobOptions) {
 		super(EventsModule, payload, options);
 	}
@@ -14,26 +16,18 @@ export default class Subscribe extends Job {
 			throw new Error("Channel must be a string");
 	}
 
-	protected override async _authorize() {
-		const [, moduleName, modelName, event, modelId] =
-			/^([a-z]+)\.([A-z]+)\.([A-z]+)\.?([A-z0-9]+)?$/.exec(
-				this._payload.channel
-			) ?? [];
-
-		let permission = `event.${this._payload.channel}`;
-
-		if (
-			moduleName === "model" &&
-			modelName &&
-			(modelId || event === "created")
-		) {
-			if (event === "created")
-				permission = `event.model.${modelName}.created`;
-			else permission = `data.${modelName}.findById.${modelId}`;
-		}
-
-		await this._context.assertPermission(permission);
-	}
+	protected override async _authorize() {}
+
+	// protected override async _authorize() {
+	// const [path, scope] = this._payload.channel.split(":");
+
+	// const EventClass = EventsModule.getEvent(path);
+
+	// const hasPermission = EventClass.hasPermission(
+	// 	this._context.getUser(),
+	// 	scope
+	// );
+	// }
 
 	protected async _execute() {
 		const socketId = this._context.getSocketId();

+ 22 - 18
backend/src/modules/EventsModule/jobs/SubscribeMany.ts

@@ -5,6 +5,8 @@ const channelRegex =
 	/^(?<moduleName>[a-z]+)\.(?<modelName>[A-z]+)\.(?<event>[A-z]+)\.?(?<modelId>[A-z0-9]+)?$/;
 
 export default class SubscribeMany extends Job {
+	protected static _hasPermission = true;
+
 	public constructor(payload?: unknown, options?: JobOptions) {
 		super(EventsModule, payload, options);
 	}
@@ -22,28 +24,30 @@ export default class SubscribeMany extends Job {
 		});
 	}
 
-	protected override async _authorize() {
-		const permissions = this._payload.channels.map((channel: string) => {
-			const { moduleName, modelName, event, modelId } =
-				channelRegex.exec(channel)?.groups ?? {};
+	protected override async _authorize() {}
 
-			let permission = `event.${channel}`;
+	// protected override async _authorize() {
+	// const permissions = this._payload.channels.map((channel: string) => {
+	// 	const { moduleName, modelName, event, modelId } =
+	// 		channelRegex.exec(channel)?.groups ?? {};
 
-			if (
-				moduleName === "model" &&
-				modelName &&
-				(modelId || event === "created")
-			) {
-				if (event === "created")
-					permission = `event.model.${modelName}.created`;
-				else permission = `data.${modelName}.findById.${modelId}`;
-			}
+	// 	let permission = `event.${channel}`;
 
-			return permission;
-		});
+	// 	if (
+	// 		moduleName === "model" &&
+	// 		modelName &&
+	// 		(modelId || event === "created")
+	// 	) {
+	// 		if (event === "created")
+	// 			permission = `event.model.${modelName}.created`;
+	// 		else permission = `data.${modelName}.findById.${modelId}`;
+	// 	}
 
-		await this._context.assertPermissions(permissions);
-	}
+	// 	return permission;
+	// });
+
+	// await this._context.assertPermissions(permissions);
+	// }
 
 	protected async _execute() {
 		const socketId = this._context.getSocketId();

+ 10 - 14
backend/src/modules/WebSocketModule.ts

@@ -13,6 +13,7 @@ import DataModule from "./DataModule";
 import { UserModel } from "./DataModule/models/users/schema";
 import { SessionModel } from "./DataModule/models/sessions/schema";
 import EventsModule from "./EventsModule";
+import assertEventDerived from "@/utils/assertEventDerived";
 
 export class WebSocketModule extends BaseModule {
 	private _httpServer?: Server;
@@ -58,22 +59,17 @@ export class WebSocketModule extends BaseModule {
 			clearInterval(this._keepAliveInterval)
 		);
 
-		await EventsModule.pSubscribe("job.*", async payload => {
-			if (
-				!payload ||
-				typeof payload !== "object" ||
-				Array.isArray(payload)
-			)
-				return;
-
-			const { socketId, callbackRef } = payload;
+		await EventsModule.pSubscribe("events.job.completed:*", async event => {
+			// assertEventDerived(event);
+			const data = event.getData();
+			const { socketId, callbackRef } = data;
 
 			if (!socketId || !callbackRef) return;
 
-			delete payload.socketId;
-			delete payload.callbackRef;
+			delete data.socketId;
+			delete data.callbackRef;
 
-			this.dispatch(socketId, "jobCallback", callbackRef, payload);
+			this.dispatch(socketId, "jobCallback", callbackRef, data);
 		});
 
 		await super._started();
@@ -232,8 +228,8 @@ export class WebSocketModule extends BaseModule {
 				throw new Error("Invalid request");
 
 			const [moduleJob, payload, options] = data;
-			const [moduleName, ...jobNameParts] = moduleJob.split(".");
-			const jobName = jobNameParts.join(".");
+			const moduleName = moduleJob.substring(0, moduleJob.indexOf("."));
+			const jobName = moduleJob.substring(moduleJob.indexOf(".") + 1);
 
 			const { callbackRef } = options ?? payload ?? {};
 

+ 7 - 0
backend/src/types/EventDerived.ts

@@ -0,0 +1,7 @@
+import Event from "@/modules/EventsModule/Event";
+
+type EventConstructorParameters = ConstructorParameters<typeof Event>;
+
+export interface EventDerived extends Event {
+	new (...args: EventConstructorParameters): Event & typeof Event;
+}

+ 14 - 0
backend/src/utils/assertEventDerived.ts

@@ -0,0 +1,14 @@
+import Event from "@/modules/EventsModule/Event";
+
+// eslint-disable-next-line @typescript-eslint/ban-types
+export default (EventClass: Function) => {
+	// Make sure the provided EventClass has Event as the parent somewhere as a parent. Not Event itself, as that constructor requires an additional constructor parameter
+	// So any class that extends Event, or that extends another class that extends Event, will be allowed.
+	let classPrototype = Object.getPrototypeOf(EventClass);
+	while (classPrototype) {
+		if (classPrototype === Event) break;
+		classPrototype = Object.getPrototypeOf(classPrototype);
+	}
+	if (!classPrototype)
+		throw new Error("Provided event class is not a event.");
+};

+ 2 - 2
frontend/src/components/AdvancedTable.vue

@@ -259,11 +259,11 @@ const subscribe = async () => {
 				.filter(row => !subscriptions.value[row._id])
 				.flatMap(row => [
 					[
-						`model.${props.model}.updated.${row._id}`,
+						`data.${props.model}.updated:${row._id}`,
 						onUpdatedCallback
 					],
 					[
-						`model.${props.model}.deleted.${row._id}`,
+						`data.${props.model}.deleted:${row._id}`,
 						onDeletedCallback
 					]
 				])

+ 8 - 8
frontend/src/stores/model.ts

@@ -273,11 +273,11 @@ export const useModelStore = defineStore("model", () => {
 				return [
 					[
 						updated,
-						`model.${model.getName()}.updated.${model.getId()}`
+						`data.${model.getName()}.updated:${model.getId()}`
 					],
 					[
 						deleted,
-						`model.${model.getName()}.deleted.${model.getId()}`
+						`data.${model.getName()}.deleted:${model.getId()}`
 					]
 				];
 			})
@@ -328,11 +328,11 @@ export const useModelStore = defineStore("model", () => {
 		const channels = Object.fromEntries(
 			missingDocuments.flatMap(({ _name, _id }) => [
 				[
-					`model.${_name}.updated.${_id}`,
+					`data.${_name}.updated:${_id}`,
 					data => onUpdatedCallback(_name, data)
 				],
 				[
-					`model.${_name}.deleted.${_id}`,
+					`data.${_name}.deleted:${_id}`,
 					data => onDeletedCallback(_name, data)
 				]
 			])
@@ -344,14 +344,14 @@ export const useModelStore = defineStore("model", () => {
 
 			const modelSubscriptions = subscriptions.filter(
 				([, { channel }]) =>
-					channel.startsWith(`model.${_name}`) &&
-					channel.endsWith(`.${_id}`)
+					channel.startsWith(`data.${_name}`) &&
+					channel.endsWith(`:${_id}`)
 			);
 			const [updated] = modelSubscriptions.find(([, { channel }]) =>
-				channel.includes(".updated.")
+				channel.includes(".updated:")
 			);
 			const [deleted] = modelSubscriptions.find(([, { channel }]) =>
-				channel.includes(".deleted.")
+				channel.includes(".deleted:")
 			);
 
 			if (!updated || !deleted) return null;