From 74ae5e053309c78cee6fd8f98f8a7ad272781260 Mon Sep 17 00:00:00 2001
From: MonaS8 <schumo62@gmail.com>
Date: Sun, 9 Mar 2025 16:41:19 +0100
Subject: [PATCH] Add data tracking

---
 .../migrations/.snapshot-collab_space.json    | 140 ++++++++++++++++--
 .../migrations/Migration20250309144112.ts     |  24 +++
 src/activities/activities.entity.ts           |  51 +++++++
 src/activities/activities.service.ts          | 102 +++++++++++++
 src/activities/activties.module.ts            |  15 ++
 src/channel/browser.gateway.ts                |   9 ++
 src/channel/channel.gateway.ts                |   5 +
 src/channel/channel.module.ts                 |   3 +-
 src/channel/notes.gateway.ts                  |   9 +-
 src/channel/whiteboard.gateway.ts             |  12 +-
 src/note/note.service.spec.ts                 |   2 +-
 src/note/note.service.ts                      |   4 +-
 12 files changed, 360 insertions(+), 16 deletions(-)
 create mode 100644 database/migrations/Migration20250309144112.ts
 create mode 100644 src/activities/activities.entity.ts
 create mode 100644 src/activities/activities.service.ts
 create mode 100644 src/activities/activties.module.ts

diff --git a/database/migrations/.snapshot-collab_space.json b/database/migrations/.snapshot-collab_space.json
index 97965b6..77f4958 100644
--- a/database/migrations/.snapshot-collab_space.json
+++ b/database/migrations/.snapshot-collab_space.json
@@ -240,23 +240,14 @@
           "length": 0,
           "mappedType": "datetime"
         },
-        "channel_id": {
-          "name": "channel_id",
-          "type": "varchar(255)",
-          "unsigned": false,
-          "autoincrement": false,
-          "primary": false,
-          "nullable": true,
-          "mappedType": "string"
-        },
         "whiteboard_canvas": {
           "name": "whiteboard_canvas",
-          "type": "varchar(255)",
+          "type": "blob",
           "unsigned": false,
           "autoincrement": false,
           "primary": false,
           "nullable": true,
-          "mappedType": "string"
+          "mappedType": "blob"
         }
       },
       "name": "rooms",
@@ -393,6 +384,133 @@
           "updateRule": "cascade"
         }
       }
+    },
+    {
+      "columns": {
+        "id": {
+          "name": "id",
+          "type": "int",
+          "unsigned": true,
+          "autoincrement": true,
+          "primary": true,
+          "nullable": false,
+          "mappedType": "integer"
+        },
+        "user_id": {
+          "name": "user_id",
+          "type": "varchar(255)",
+          "unsigned": false,
+          "autoincrement": false,
+          "primary": false,
+          "nullable": false,
+          "mappedType": "string"
+        },
+        "room_id": {
+          "name": "room_id",
+          "type": "int",
+          "unsigned": true,
+          "autoincrement": false,
+          "primary": false,
+          "nullable": false,
+          "mappedType": "integer"
+        },
+        "activity": {
+          "name": "activity",
+          "type": "varchar(255)",
+          "unsigned": false,
+          "autoincrement": false,
+          "primary": false,
+          "nullable": false,
+          "mappedType": "string"
+        },
+        "numeric_value": {
+          "name": "numeric_value",
+          "type": "int",
+          "unsigned": false,
+          "autoincrement": false,
+          "primary": false,
+          "nullable": true,
+          "mappedType": "integer"
+        },
+        "note_id": {
+          "name": "note_id",
+          "type": "int",
+          "unsigned": true,
+          "autoincrement": false,
+          "primary": false,
+          "nullable": true,
+          "mappedType": "integer"
+        },
+        "timestamp": {
+          "name": "timestamp",
+          "type": "datetime",
+          "unsigned": false,
+          "autoincrement": false,
+          "primary": false,
+          "nullable": false,
+          "length": 0,
+          "mappedType": "datetime"
+        }
+      },
+      "name": "activities",
+      "indexes": [
+        {
+          "columnNames": [
+            "room_id"
+          ],
+          "composite": false,
+          "keyName": "activities_room_id_index",
+          "primary": false,
+          "unique": false
+        },
+        {
+          "columnNames": [
+            "note_id"
+          ],
+          "composite": false,
+          "keyName": "activities_note_id_index",
+          "primary": false,
+          "unique": false
+        },
+        {
+          "keyName": "PRIMARY",
+          "columnNames": [
+            "id"
+          ],
+          "composite": false,
+          "primary": true,
+          "unique": true
+        }
+      ],
+      "checks": [],
+      "foreignKeys": {
+        "activities_room_id_foreign": {
+          "constraintName": "activities_room_id_foreign",
+          "columnNames": [
+            "room_id"
+          ],
+          "localTableName": "activities",
+          "referencedColumnNames": [
+            "id"
+          ],
+          "referencedTableName": "rooms",
+          "deleteRule": "cascade",
+          "updateRule": "cascade"
+        },
+        "activities_note_id_foreign": {
+          "constraintName": "activities_note_id_foreign",
+          "columnNames": [
+            "note_id"
+          ],
+          "localTableName": "activities",
+          "referencedColumnNames": [
+            "id"
+          ],
+          "referencedTableName": "notes",
+          "deleteRule": "cascade",
+          "updateRule": "cascade"
+        }
+      }
     }
   ]
 }
diff --git a/database/migrations/Migration20250309144112.ts b/database/migrations/Migration20250309144112.ts
new file mode 100644
index 0000000..14614f5
--- /dev/null
+++ b/database/migrations/Migration20250309144112.ts
@@ -0,0 +1,24 @@
+import { Migration } from '@mikro-orm/migrations';
+
+export class Migration20250309144112 extends Migration {
+
+  async up(): Promise<void> {
+    this.addSql('create table `activities` (`id` int unsigned not null auto_increment primary key, `user_id` varchar(255) not null, `room_id` int unsigned not null, `activity` varchar(255) not null, `numeric_value` int null, `note_id` int unsigned null, `timestamp` datetime not null) default character set utf8mb4 engine = InnoDB;');
+    this.addSql('alter table `activities` add index `activities_room_id_index`(`room_id`);');
+    this.addSql('alter table `activities` add index `activities_note_id_index`(`note_id`);');
+
+    this.addSql('alter table `activities` add constraint `activities_room_id_foreign` foreign key (`room_id`) references `rooms` (`id`) on update cascade on delete cascade;');
+    this.addSql('alter table `activities` add constraint `activities_note_id_foreign` foreign key (`note_id`) references `notes` (`id`) on update cascade on delete cascade;');
+
+    this.addSql('alter table `rooms` modify `whiteboard_canvas` blob;');
+    this.addSql('alter table `rooms` drop `channel_id`;');
+  }
+
+  async down(): Promise<void> {
+    this.addSql('drop table if exists `activities`;');
+
+    this.addSql('alter table `rooms` add `channel_id` varchar(255) null;');
+    this.addSql('alter table `rooms` modify `whiteboard_canvas` varchar(255);');
+  }
+
+}
diff --git a/src/activities/activities.entity.ts b/src/activities/activities.entity.ts
new file mode 100644
index 0000000..6e3ef42
--- /dev/null
+++ b/src/activities/activities.entity.ts
@@ -0,0 +1,51 @@
+import {
+  Entity,
+  PrimaryKey,
+  Property,
+  ManyToOne,
+} from '@mikro-orm/core';
+import { Room } from '../room/room.entity';
+import { Note } from '../note/note.entity';
+
+
+
+/**
+ * Represents a tracked activity entity.
+ */
+@Entity({ tableName: 'activities' })
+export class Activity {
+  @PrimaryKey()
+  id!: number;
+
+  @Property()
+  userId!: string;
+
+  @ManyToOne({ onDelete: 'cascade', hidden: true })
+  room: Room;
+
+  @Property()
+  activity!: string;
+
+  @Property({nullable: true})
+  numericValue?: number;
+
+  @ManyToOne({ onDelete: 'cascade', hidden: true, nullable: true })
+  note?: Note;
+
+  @Property()
+  timestamp: Date = new Date();
+
+
+  /**
+   * Constructor to create a new tracked activity instance.
+   * @param userId - The user's id.
+   * @param room - The associated Room entity.
+   * @param activity - The activities user triggers.
+   */
+    constructor(userId: string, room: Room, activity: string) {
+        this.userId = userId;
+        this.room = room;
+        this.activity = activity;
+        }
+    
+    }
\ No newline at end of file
diff --git a/src/activities/activities.service.ts b/src/activities/activities.service.ts
new file mode 100644
index 0000000..166c513
--- /dev/null
+++ b/src/activities/activities.service.ts
@@ -0,0 +1,102 @@
+import { Injectable } from '@nestjs/common';
+import { EntityManager } from '@mikro-orm/core';
+import { InjectRepository } from '@mikro-orm/nestjs';
+import { EntityRepository } from '@mikro-orm/mysql';
+import { Room } from '../room/room.entity';
+import { Activity } from './activities.entity';
+import { Note } from 'src/note/note.entity';
+import { tr } from '@faker-js/faker';
+
+/**
+ * A service responsible for managing to track the activities.
+ */
+@Injectable()
+export class TrackingService {
+  /**
+    * Initializes the TrackingService.
+    * @param em - The EntityManager instance provided by MikroORM.
+    * @param repository - The repository for the Activity entity.
+    */
+  constructor(
+    private readonly em: EntityManager,
+    @InjectRepository(Activity)
+    private readonly repository: EntityRepository<Activity>,
+  ) { }
+
+  /**
+   * Adds a note activity to the tracking system.
+   * @param userId - The ID of the user performing the activity.
+   * @param room - The room where the activity is taking place.
+   * @param activity - A description of the activity.
+   * @param NumericValue - A numeric value associated with the activity.
+   */
+  public async addNoteCreateActivity(userId: string, room: Room, note: Note) {
+    const trackingNote = new Activity(userId, room, "note.create");
+    trackingNote.note = note;
+    await this.em.persistAndFlush(trackingNote);
+    return trackingNote;
+  }
+
+  public async addNoteUpdateActivity(userId: string, room: Room, note: Note, charactersAdded: number) {
+    const trackingNote = new Activity(userId, room, "note.update");
+    trackingNote.note = note;
+    trackingNote.numericValue = charactersAdded;
+    await this.em.persistAndFlush(trackingNote);
+    return trackingNote;
+  }
+
+  public async addWhiteboardActivity(userId: string, room: Room) {
+    const trackingWhiteboard = new Activity(userId, room, "whiteboard");
+    await this.em.persistAndFlush(trackingWhiteboard);
+    return trackingWhiteboard;
+  }
+
+  public async addVideoActivity(userId: string, room: Room, isActive: boolean) {
+    const activity = isActive ? "video.on" : "video.off";
+    const trackingVideo = new Activity(userId, room, activity);
+    await this.em.persistAndFlush(trackingVideo);
+    return trackingVideo;
+  }
+
+  public async addAudioActivity(userId: string, room: Room, isActive: boolean) {
+    const activity = isActive ? "audio.on" : "audio.off";
+    const trackingAudio = new Activity(userId, room, activity);
+    await this.em.persistAndFlush(trackingAudio);
+    return trackingAudio;
+  }
+
+  public async addBrowserOpenActivity(userId: string, room: Room) {
+    const trackingBrowser = new Activity(userId, room, "browser.open");
+    await this.em.persistAndFlush(trackingBrowser);
+    return trackingBrowser;
+  }
+
+  public async addBrowserCloseActivity(userId: string, room: Room) {
+    const trackingBrowser = new Activity(userId, room, "browser.close");
+    await this.em.persistAndFlush(trackingBrowser);
+    return trackingBrowser;
+  }
+
+  public async addBrowserMouseActivity(userId: string, room: Room) {
+    const trackingBrowser = new Activity(userId, room, "browser.mouse");
+    await this.em.persistAndFlush(trackingBrowser);
+    return trackingBrowser;
+  }
+
+  public async addBrowserKeyActivity(userId: string, room: Room) {
+    const trackingBrowser = new Activity(userId, room, "browser.key");
+    await this.em.persistAndFlush(trackingBrowser);
+    return trackingBrowser;
+  }
+
+
+
+
+}
+/**public async trackAudioVolume(userId: string, room: Room, volume: number) {
+    const audioActivity = new Activity(userId, room, 'Audio volume', volume);
+    await this.em.persistAndFlush(audioActivity);
+    return audioActivity;
+    }
+ 
+  */
\ No newline at end of file
diff --git a/src/activities/activties.module.ts b/src/activities/activties.module.ts
new file mode 100644
index 0000000..bbe6b5c
--- /dev/null
+++ b/src/activities/activties.module.ts
@@ -0,0 +1,15 @@
+import { Global, Module } from '@nestjs/common';
+import { MikroOrmModule } from '@mikro-orm/nestjs';
+import { TrackingService } from './activities.service';
+import { Activity } from './activities.entity';
+
+/**
+ * A global module that encapsulates features related to tracking.
+ */
+@Global()
+@Module({
+  imports: [MikroOrmModule.forFeature([Activity])],
+  providers: [TrackingService],
+  exports: [TrackingService],
+})
+export class ActivitiesModule {}
diff --git a/src/channel/browser.gateway.ts b/src/channel/browser.gateway.ts
index 93754fc..ed8e106 100644
--- a/src/channel/browser.gateway.ts
+++ b/src/channel/browser.gateway.ts
@@ -11,6 +11,7 @@ import { ChannelService } from './channel.service';
 import * as process from 'process';
 import { BrowserService } from '../browser/browser.service';
 import * as dotenv from 'dotenv';
+import { TrackingService } from '../activities/activities.service';
 
 dotenv.config();
 
@@ -38,6 +39,7 @@ export class BrowserGateway {
     private orm: MikroORM,
     private channels: ChannelService,
     private browserService: BrowserService,
+    private trackingService: TrackingService,
   ) {}
 
   /**
@@ -62,6 +64,8 @@ export class BrowserGateway {
     );
     this.server.to(channel.id).emit('open-website', peerId);
 
+    await this.trackingService.addBrowserOpenActivity(client.id, channel.room);
+    
     return true;
   }
 
@@ -79,6 +83,8 @@ export class BrowserGateway {
     await this.browserService.closeBrowserContext(channel.id);
     this.server.to(channel.id).emit('close-browser');
 
+    await this.trackingService.addBrowserCloseActivity(client.id, channel.room);
+
     return true;
   }
 
@@ -115,6 +121,8 @@ export class BrowserGateway {
 
     await this.browserService.mouseDown(channel.id);
 
+    await this.trackingService.addBrowserMouseActivity(client.id, channel.room);
+
     return true;
   }
 
@@ -150,6 +158,7 @@ export class BrowserGateway {
     const channel = await this.channels.fromClientOrFail(client);
 
     await this.browserService.keyDown(channel.id, payload.key);
+    await this.trackingService.addBrowserKeyActivity(client.id, channel.room);
 
     return true;
   }
diff --git a/src/channel/channel.gateway.ts b/src/channel/channel.gateway.ts
index b1dee11..440a942 100644
--- a/src/channel/channel.gateway.ts
+++ b/src/channel/channel.gateway.ts
@@ -14,6 +14,7 @@ import * as process from 'process';
 import * as dotenv from 'dotenv';
 import { BrowserService } from '../browser/browser.service';
 import { UnauthorizedException } from '@nestjs/common';
+import { TrackingService } from '../activities/activities.service';
 
 dotenv.config();
 
@@ -37,6 +38,7 @@ export class ChannelGateway implements OnGatewayConnection {
     private orm: MikroORM,
     private channels: ChannelService,
     private browsers: BrowserService,
+    private trackingService: TrackingService,
   ) {}
 
   // todo: add authentication - only a logged in teacher should be able to open a room
@@ -261,6 +263,9 @@ export class ChannelGateway implements OnGatewayConnection {
       audio: payload.audio,
     });
 
+    await this.trackingService.addVideoActivity(client.id, channel.room, payload.video);
+    await this.trackingService.addVideoActivity(client.id, channel.room, payload.audio);
+
     return true;
   }
 
diff --git a/src/channel/channel.module.ts b/src/channel/channel.module.ts
index 0bb409e..156b48b 100644
--- a/src/channel/channel.module.ts
+++ b/src/channel/channel.module.ts
@@ -5,12 +5,13 @@ import { BrowserGateway } from './browser.gateway';
 import { NotesGateway } from './notes.gateway';
 import { NoteModule } from '../note/note.module';
 import { WhiteboardGateway } from './whiteboard.gateway';
+import { ActivitiesModule } from 'src/activities/activties.module';
 
 /**
  * Module for handling real-time communication and operations related to channels.
  */
 @Module({
-  imports: [NoteModule],
+  imports: [NoteModule, ActivitiesModule],
   providers: [
     ChannelGateway,
     NotesGateway,
diff --git a/src/channel/notes.gateway.ts b/src/channel/notes.gateway.ts
index e604429..ebe30bb 100644
--- a/src/channel/notes.gateway.ts
+++ b/src/channel/notes.gateway.ts
@@ -10,6 +10,7 @@ import { Server, Socket } from 'socket.io';
 import { ChannelService } from './channel.service';
 import * as dotenv from 'dotenv';
 import { NoteService } from '../note/note.service';
+import { TrackingService } from '../activities/activities.service';
 
 dotenv.config();
 
@@ -31,11 +32,13 @@ export class NotesGateway {
    * @param orm - MikroORM instance for database interactions.
    * @param channels - Instance of ChannelService for managing channels.
    * @param notes - Instance of NoteService for managing notes.
+   * @param trackingService - Instance of TrackingService for tracking activities.
    */
   constructor(
     private orm: MikroORM,
     private channels: ChannelService,
     private notes: NoteService,
+    private trackingService: TrackingService,
   ) {}
 
   /**
@@ -55,6 +58,8 @@ export class NotesGateway {
 
     client.broadcast.to(channel.id).emit('note-added', note);
 
+    await this.trackingService.addNoteCreateActivity(client.id, channel.room, note);
+
     return note;
   }
 
@@ -71,13 +76,15 @@ export class NotesGateway {
     @MessageBody() payload: { noteId: number; content: string },
   ) {
     const channel = this.channels.fromClientOrFail(client);
-    await this.notes.updateNote(payload.noteId, payload.content);
+    const {note, charactersAdded} = await this.notes.updateNote(payload.noteId, payload.content,);
 
     client.broadcast.to(channel.id).emit('note-updated', {
       noteId: payload.noteId,
       content: payload.content,
     });
 
+    await this.trackingService.addNoteUpdateActivity(client.id, channel.room, note, charactersAdded);
+
     return true;
   }
 
diff --git a/src/channel/whiteboard.gateway.ts b/src/channel/whiteboard.gateway.ts
index ed324d4..fb9dfb1 100644
--- a/src/channel/whiteboard.gateway.ts
+++ b/src/channel/whiteboard.gateway.ts
@@ -8,6 +8,8 @@ import {
 import { Server, Socket } from 'socket.io';
 import * as dotenv from 'dotenv';
 import { ChannelService } from './channel.service';
+import { TrackingService } from '../activities/activities.service';
+import { MikroORM, UseRequestContext } from '@mikro-orm/core';
 
 dotenv.config();
 
@@ -27,8 +29,13 @@ export class WhiteboardGateway {
   /**
    * Constructor of WhiteboardGateway.
    * @param channels - Instance of ChannelService for managing channels.
+   * @param trackingService - Instance of TrackingService for tracking activities.
    */
-  constructor(private channels: ChannelService) {}
+  constructor(
+    private orm: MikroORM,
+    private channels: ChannelService,
+    private trackingService: TrackingService,
+  ) {}
 
   /**
    * Subscribe to the 'whiteboard-change' event to handle whiteboard canvas changes.
@@ -36,6 +43,7 @@ export class WhiteboardGateway {
    * @param payload - The message body containing the updated canvas JSON.
    */
   @SubscribeMessage('whiteboard-change')
+  @UseRequestContext()
   public async whiteboardChange(
     @ConnectedSocket() client: Socket,
     @MessageBody() payload: { canvas: string },
@@ -47,5 +55,7 @@ export class WhiteboardGateway {
 
     // Broadcast the whiteboard change to all clients in the channel
     client.broadcast.to(channel.id).emit('whiteboard-change', payload);
+
+    await this.trackingService.addWhiteboardActivity(client.id, channel.room);
   }
 }
diff --git a/src/note/note.service.spec.ts b/src/note/note.service.spec.ts
index a4d5c23..f3f9c3b 100644
--- a/src/note/note.service.spec.ts
+++ b/src/note/note.service.spec.ts
@@ -67,7 +67,7 @@ describe('NoteService', () => {
     it('should update note content correctly', async () => {
       const updatedContent = 'Updated Test Note Content';
 
-      const updatedNote = await service.updateNote(1, updatedContent);
+      const {note: updatedNote, charactersAdded} = await service.updateNote(1, updatedContent);
 
       expect(updatedNote.content).toBe(updatedContent);
       expect(entityManager.persistAndFlush).toHaveBeenCalledWith(updatedNote);
diff --git a/src/note/note.service.ts b/src/note/note.service.ts
index 717702f..6766774 100644
--- a/src/note/note.service.ts
+++ b/src/note/note.service.ts
@@ -42,9 +42,11 @@ export class NoteService {
    */
   public async updateNote(id: number, content: string) {
     const note = await this.repository.findOneOrFail({ id });
+    // Calculate the number of characters added to the note. (For tracking purposes)
+    const charactersAdded = content.length - (note.content?.length ?? 0);
     note.content = content;
     await this.em.persistAndFlush(note);
-    return note;
+    return {note, charactersAdded};
   }
 
   /**
-- 
GitLab