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