From 10577defd3931ef252f32769245ee5aa22a9e590 Mon Sep 17 00:00:00 2001 From: chris Date: Mon, 29 Jun 2026 20:40:55 +0100 Subject: [PATCH] issues resolved --- BackendAcademy/package-lock.json | 153 +++++++++++++++++- BackendAcademy/src/app.module.ts | 2 + .../src/sessions/attendance.controller.ts | 43 +++++ .../src/sessions/attendance.entity.ts | 27 ++++ .../src/sessions/attendance.service.ts | 90 +++++++++++ .../dto/get-session-attendance-stats.dto.ts | 18 +++ .../dto/join-session-attendance.dto.ts | 14 ++ .../dto/leave-session-attendance.dto.ts | 14 ++ .../src/sessions/sessions.module.ts | 14 ++ TODO.md | 33 ++++ 10 files changed, 407 insertions(+), 1 deletion(-) create mode 100644 BackendAcademy/src/sessions/attendance.controller.ts create mode 100644 BackendAcademy/src/sessions/attendance.entity.ts create mode 100644 BackendAcademy/src/sessions/attendance.service.ts create mode 100644 BackendAcademy/src/sessions/dto/get-session-attendance-stats.dto.ts create mode 100644 BackendAcademy/src/sessions/dto/join-session-attendance.dto.ts create mode 100644 BackendAcademy/src/sessions/dto/leave-session-attendance.dto.ts create mode 100644 BackendAcademy/src/sessions/sessions.module.ts create mode 100644 TODO.md diff --git a/BackendAcademy/package-lock.json b/BackendAcademy/package-lock.json index 1452617b0..fae638b28 100644 --- a/BackendAcademy/package-lock.json +++ b/BackendAcademy/package-lock.json @@ -13,8 +13,12 @@ "@nestjs/core": "^10.0.0", "@nestjs/jwt": "^10.2.0", "@nestjs/platform-express": "^10.0.0", + "@nestjs/swagger": "^7.4.2", "@nestjs/throttler": "^6.5.0", "@nestjs/typeorm": "^10.0.2", + "class-transformer": "^0.5.1", + "class-validator": "^0.14.0", + "helmet": "^8.1.0", "joi": "^17.13.4", "reflect-metadata": "^0.1.13", "rxjs": "^7.8.1", @@ -1410,6 +1414,12 @@ "node": ">=8" } }, + "node_modules/@microsoft/tsdoc": { + "version": "0.15.1", + "resolved": "https://registry.npmjs.org/@microsoft/tsdoc/-/tsdoc-0.15.1.tgz", + "integrity": "sha512-4aErSrCR/On/e5G2hDP0wjooqDdauzEbIq8hIkIe5pXV0rtWJZvdCEKL0ykZxex+IxIwBp0eGeV48hQN07dXtw==", + "license": "MIT" + }, "node_modules/@nestjs/cli": { "version": "10.4.9", "dev": true, @@ -1564,6 +1574,26 @@ "@nestjs/common": "^8.0.0 || ^9.0.0 || ^10.0.0" } }, + "node_modules/@nestjs/mapped-types": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@nestjs/mapped-types/-/mapped-types-2.0.5.tgz", + "integrity": "sha512-bSJv4pd6EY99NX9CjBIyn4TVDoSit82DUZlL4I3bqNfy5Gt+gXTa86i3I/i0iIV9P4hntcGM5GyO+FhZAhxtyg==", + "license": "MIT", + "peerDependencies": { + "@nestjs/common": "^8.0.0 || ^9.0.0 || ^10.0.0", + "class-transformer": "^0.4.0 || ^0.5.0", + "class-validator": "^0.13.0 || ^0.14.0", + "reflect-metadata": "^0.1.12 || ^0.2.0" + }, + "peerDependenciesMeta": { + "class-transformer": { + "optional": true + }, + "class-validator": { + "optional": true + } + } + }, "node_modules/@nestjs/platform-express": { "version": "10.4.22", "license": "MIT", @@ -1603,6 +1633,57 @@ "dev": true, "license": "MIT" }, + "node_modules/@nestjs/swagger": { + "version": "7.4.2", + "resolved": "https://registry.npmjs.org/@nestjs/swagger/-/swagger-7.4.2.tgz", + "integrity": "sha512-Mu6TEn1M/owIvAx2B4DUQObQXqo2028R2s9rSZ/hJEgBK95+doTwS0DjmVA2wTeZTyVtXOoN7CsoM5pONBzvKQ==", + "license": "MIT", + "dependencies": { + "@microsoft/tsdoc": "^0.15.0", + "@nestjs/mapped-types": "2.0.5", + "js-yaml": "4.1.0", + "lodash": "4.17.21", + "path-to-regexp": "3.3.0", + "swagger-ui-dist": "5.17.14" + }, + "peerDependencies": { + "@fastify/static": "^6.0.0 || ^7.0.0", + "@nestjs/common": "^9.0.0 || ^10.0.0", + "@nestjs/core": "^9.0.0 || ^10.0.0", + "class-transformer": "*", + "class-validator": "*", + "reflect-metadata": "^0.1.12 || ^0.2.0" + }, + "peerDependenciesMeta": { + "@fastify/static": { + "optional": true + }, + "class-transformer": { + "optional": true + }, + "class-validator": { + "optional": true + } + } + }, + "node_modules/@nestjs/swagger/node_modules/js-yaml": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", + "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/@nestjs/swagger/node_modules/lodash": { + "version": "4.17.21", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", + "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", + "license": "MIT" + }, "node_modules/@nestjs/testing": { "version": "10.4.22", "dev": true, @@ -2128,6 +2209,12 @@ "version": "1.3.5", "license": "MIT" }, + "node_modules/@types/validator": { + "version": "13.15.10", + "resolved": "https://registry.npmjs.org/@types/validator/-/validator-13.15.10.tgz", + "integrity": "sha512-T8L6i7wCuyoK8A/ZeLYt1+q0ty3Zb9+qbSSvrIVitzT3YjZqkTZ40IbRsPanlB4h1QB3JVL1SYCdR6ngtFYcuA==", + "license": "MIT" + }, "node_modules/@types/yargs": { "version": "17.0.35", "dev": true, @@ -2657,7 +2744,6 @@ }, "node_modules/argparse": { "version": "2.0.1", - "dev": true, "license": "Python-2.0" }, "node_modules/array-flatten": { @@ -3166,6 +3252,23 @@ "dev": true, "license": "MIT" }, + "node_modules/class-transformer": { + "version": "0.5.1", + "resolved": "https://registry.npmjs.org/class-transformer/-/class-transformer-0.5.1.tgz", + "integrity": "sha512-SQa1Ws6hUbfC98vKGxZH3KFY0Y1lm5Zm0SY8XX9zbK7FJCyVEac3ATW0RIpwzW+oOfmHE5PMPufDG9hCfoEOMw==", + "license": "MIT" + }, + "node_modules/class-validator": { + "version": "0.14.4", + "resolved": "https://registry.npmjs.org/class-validator/-/class-validator-0.14.4.tgz", + "integrity": "sha512-AwNusCCam51q703dW82x95tOqQp6oC9HNUl724KxJJOfnKscI8dOloXFgyez7LbTTKWuRBA37FScqVbJEoq8Yw==", + "license": "MIT", + "dependencies": { + "@types/validator": "^13.15.3", + "libphonenumber-js": "^1.11.1", + "validator": "^13.15.22" + } + }, "node_modules/cli-cursor": { "version": "3.1.0", "dev": true, @@ -4387,6 +4490,21 @@ "dev": true, "license": "ISC" }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/function-bind": { "version": "1.1.2", "license": "MIT", @@ -4651,6 +4769,18 @@ "node": ">= 0.4" } }, + "node_modules/helmet": { + "version": "8.2.0", + "resolved": "https://registry.npmjs.org/helmet/-/helmet-8.2.0.tgz", + "integrity": "sha512-DRgTIUgnWcJ62KyarxxziuqYxKGnR6Rgg19BlbucN/dpmJbl1XOit6qvoOX0ZT+HhWe5OUVhU/a1zpGyc1xA0Q==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/EvanHahn" + } + }, "node_modules/html-escaper": { "version": "2.0.2", "dev": true, @@ -5835,6 +5965,12 @@ "node": ">= 0.8.0" } }, + "node_modules/libphonenumber-js": { + "version": "1.13.7", + "resolved": "https://registry.npmjs.org/libphonenumber-js/-/libphonenumber-js-1.13.7.tgz", + "integrity": "sha512-rvr3HIMdOgzhz1RFGjftji+wjoAFlzhqCNqJOU/MKTZQ8d9NZxAR/tI+0weDicyoucqVR0U1GCniqHJ0f8aM2A==", + "license": "MIT" + }, "node_modules/lines-and-columns": { "version": "1.2.4", "dev": true, @@ -7458,6 +7594,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/swagger-ui-dist": { + "version": "5.17.14", + "resolved": "https://registry.npmjs.org/swagger-ui-dist/-/swagger-ui-dist-5.17.14.tgz", + "integrity": "sha512-CVbSfaLpstV65OnSjbXfVd6Sta3q3F7Cj/yYuvHMp1P90LztOLs6PfUnKEVAeiIVQt9u2SaPwv0LiH/OyMjHRw==", + "license": "Apache-2.0" + }, "node_modules/symbol-observable": { "version": "4.0.0", "dev": true, @@ -8319,6 +8461,15 @@ "node": ">=10.12.0" } }, + "node_modules/validator": { + "version": "13.15.35", + "resolved": "https://registry.npmjs.org/validator/-/validator-13.15.35.tgz", + "integrity": "sha512-TQ5pAGhd5whStmqWvYF4OjQROlmv9SMFVt37qoCBdqRffuuklWYQlCNnEs2ZaIBD1kZRNnikiZOS1eqgkar0iw==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, "node_modules/vary": { "version": "1.1.2", "license": "MIT", diff --git a/BackendAcademy/src/app.module.ts b/BackendAcademy/src/app.module.ts index 9305580c6..945bd42ab 100644 --- a/BackendAcademy/src/app.module.ts +++ b/BackendAcademy/src/app.module.ts @@ -16,6 +16,7 @@ import { SocialModule } from './social/social.module'; import { OnboardingModule } from './onboarding/onboarding.module'; import { LessonModule } from './lessons/lesson.module'; import { TaskModule } from './tasks/task.module'; +import { SessionsModule } from './sessions/sessions.module'; @Module({ imports: [ @@ -38,6 +39,7 @@ import { TaskModule } from './tasks/task.module'; OnboardingModule, LessonModule, TaskModule, + SessionsModule, ], controllers: [AppController], providers: [ diff --git a/BackendAcademy/src/sessions/attendance.controller.ts b/BackendAcademy/src/sessions/attendance.controller.ts new file mode 100644 index 000000000..38cb3ab6d --- /dev/null +++ b/BackendAcademy/src/sessions/attendance.controller.ts @@ -0,0 +1,43 @@ +import { Body, Controller, Get, Param, Post } from '@nestjs/common'; +import { AttendanceService } from './attendance.service'; +import { JoinSessionAttendanceDto } from './dto/join-session-attendance.dto'; +import { LeaveSessionAttendanceDto } from './dto/leave-session-attendance.dto'; + +@Controller('sessions/attendance') +export class AttendanceController { + constructor(private readonly attendanceService: AttendanceService) {} + + @Post('join') + async join(@Body() dto: JoinSessionAttendanceDto) { + const record = await this.attendanceService.join(dto.sessionKey, dto.userId); + return { + id: record.id, + sessionKey: record.sessionKey, + userId: record.userId, + joinedAt: record.joinedAt, + leftAt: record.leftAt, + durationSeconds: record.durationSeconds, + isActive: record.isActive, + }; + } + + @Post('leave') + async leave(@Body() dto: LeaveSessionAttendanceDto) { + const record = await this.attendanceService.leave(dto.sessionKey, dto.userId); + return { + id: record.id, + sessionKey: record.sessionKey, + userId: record.userId, + joinedAt: record.joinedAt, + leftAt: record.leftAt, + durationSeconds: record.durationSeconds, + isActive: record.isActive, + }; + } + + @Get(':sessionKey/stats') + async stats(@Param('sessionKey') sessionKey: string) { + return this.attendanceService.getSessionStats(sessionKey); + } +} + diff --git a/BackendAcademy/src/sessions/attendance.entity.ts b/BackendAcademy/src/sessions/attendance.entity.ts new file mode 100644 index 000000000..cd20bc357 --- /dev/null +++ b/BackendAcademy/src/sessions/attendance.entity.ts @@ -0,0 +1,27 @@ +import { Column, CreateDateColumn, Entity, Index, PrimaryGeneratedColumn } from 'typeorm'; + +@Entity('session_attendance') +@Index(['sessionKey', 'userId'], { unique: false }) +export class AttendanceEntity { + @PrimaryGeneratedColumn('uuid') + id!: string; + + @Column({ type: 'varchar', length: 128 }) + sessionKey!: string; + + @Column({ type: 'varchar', length: 128 }) + userId!: string; + + @CreateDateColumn({ type: 'timestamptz' }) + joinedAt!: Date; + + @Column({ type: 'timestamptz', nullable: true }) + leftAt: Date | null = null; + + @Column({ type: 'int', nullable: true }) + durationSeconds: number | null = null; + + @Column({ type: 'boolean', default: true }) + isActive!: boolean; +} + diff --git a/BackendAcademy/src/sessions/attendance.service.ts b/BackendAcademy/src/sessions/attendance.service.ts new file mode 100644 index 000000000..247dbafa2 --- /dev/null +++ b/BackendAcademy/src/sessions/attendance.service.ts @@ -0,0 +1,90 @@ +import { BadRequestException, Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { AttendanceEntity } from './attendance.entity'; + +@Injectable() +export class AttendanceService { + constructor( + @InjectRepository(AttendanceEntity) + private readonly attendanceRepo: Repository, + ) {} + + async join(sessionKey: string, userId: string): Promise { + const now = new Date(); + + const existing = await this.attendanceRepo.findOne({ + where: { + sessionKey, + userId, + isActive: true, + }, + order: { joinedAt: 'DESC' }, + }); + + if (existing) { + return existing; + } + + const record = this.attendanceRepo.create({ + sessionKey, + userId, + joinedAt: now, + isActive: true, + leftAt: null, + durationSeconds: null, + }); + + return this.attendanceRepo.save(record); + } + + async leave(sessionKey: string, userId: string): Promise { + const existing = await this.attendanceRepo.findOne({ + where: { + sessionKey, + userId, + isActive: true, + }, + order: { joinedAt: 'DESC' }, + }); + + if (!existing) { + throw new BadRequestException('No active attendance found for this user/session'); + } + + const now = new Date(); + const durationMs = now.getTime() - existing.joinedAt.getTime(); + const durationSeconds = Math.max(0, Math.floor(durationMs / 1000)); + + existing.isActive = false; + existing.leftAt = now; + existing.durationSeconds = durationSeconds; + + return this.attendanceRepo.save(existing); + } + + async getSessionStats(sessionKey: string): Promise<{ + presentCount: number; + totalJoins: number; + totalDurationSeconds: number; + }> { + const active = await this.attendanceRepo.count({ + where: { + sessionKey, + isActive: true, + }, + }); + + const all = await this.attendanceRepo.find({ where: { sessionKey } }); + + const totalJoins = all.length; + const totalDurationSeconds = all.reduce((acc, r) => acc + (r.durationSeconds ?? 0), 0); + + return { + presentCount: active, + totalJoins, + totalDurationSeconds, + }; + } +} + diff --git a/BackendAcademy/src/sessions/dto/get-session-attendance-stats.dto.ts b/BackendAcademy/src/sessions/dto/get-session-attendance-stats.dto.ts new file mode 100644 index 000000000..33492c779 --- /dev/null +++ b/BackendAcademy/src/sessions/dto/get-session-attendance-stats.dto.ts @@ -0,0 +1,18 @@ +import { IsInt, IsNotEmpty, IsObject, IsOptional, IsString, MaxLength, ValidateNested } from 'class-validator'; + +export class SessionAttendanceStatsDto { + @IsString() + @IsNotEmpty() + @MaxLength(128) + sessionKey!: string; + + @IsInt() + presentCount!: number; + + @IsInt() + totalJoins!: number; + + @IsInt() + totalDurationSeconds!: number; +} + diff --git a/BackendAcademy/src/sessions/dto/join-session-attendance.dto.ts b/BackendAcademy/src/sessions/dto/join-session-attendance.dto.ts new file mode 100644 index 000000000..498876e5c --- /dev/null +++ b/BackendAcademy/src/sessions/dto/join-session-attendance.dto.ts @@ -0,0 +1,14 @@ +import { IsNotEmpty, IsString, MaxLength } from 'class-validator'; + +export class JoinSessionAttendanceDto { + @IsString() + @IsNotEmpty() + @MaxLength(128) + sessionKey!: string; + + @IsString() + @IsNotEmpty() + @MaxLength(128) + userId!: string; +} + diff --git a/BackendAcademy/src/sessions/dto/leave-session-attendance.dto.ts b/BackendAcademy/src/sessions/dto/leave-session-attendance.dto.ts new file mode 100644 index 000000000..d1e24ef77 --- /dev/null +++ b/BackendAcademy/src/sessions/dto/leave-session-attendance.dto.ts @@ -0,0 +1,14 @@ +import { IsNotEmpty, IsString, MaxLength } from 'class-validator'; + +export class LeaveSessionAttendanceDto { + @IsString() + @IsNotEmpty() + @MaxLength(128) + sessionKey!: string; + + @IsString() + @IsNotEmpty() + @MaxLength(128) + userId!: string; +} + diff --git a/BackendAcademy/src/sessions/sessions.module.ts b/BackendAcademy/src/sessions/sessions.module.ts new file mode 100644 index 000000000..e02a7ba79 --- /dev/null +++ b/BackendAcademy/src/sessions/sessions.module.ts @@ -0,0 +1,14 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { AttendanceEntity } from './attendance.entity'; +import { AttendanceController } from './attendance.controller'; +import { AttendanceService } from './attendance.service'; + +@Module({ + imports: [TypeOrmModule.forFeature([AttendanceEntity])], + controllers: [AttendanceController], + providers: [AttendanceService], + exports: [AttendanceService], +}) +export class SessionsModule {} + diff --git a/TODO.md b/TODO.md new file mode 100644 index 000000000..73d01f883 --- /dev/null +++ b/TODO.md @@ -0,0 +1,33 @@ +# TODO - Live session attendance tracking (BackendAcademy) + +## Step 1: Add persistence model +- Create `BackendAcademy/src/sessions/attendance.entity.ts` using TypeORM. +- Decide primary key and fields: `sessionKey`, `userId`, `joinedAt`, `leftAt`, `durationSeconds`. + +## Step 2: Implement attendance service +- Create `BackendAcademy/src/sessions/attendance.service.ts`. +- Add methods: `join`, `leave`, `getSessionStats`. +- Join is idempotent per `(sessionKey,userId)` while active. + +## Step 3: Implement controller + DTOs +- Create `BackendAcademy/src/sessions/dto/join-session-attendance.dto.ts`. +- Create `BackendAcademy/src/sessions/dto/leave-session-attendance.dto.ts`. +- Create `BackendAcademy/src/sessions/dto/get-session-attendance-stats.dto.ts` (if needed). +- Create `BackendAcademy/src/sessions/attendance.controller.ts` with endpoints: + - `POST /api/v1/sessions/attendance/join` + - `POST /api/v1/sessions/attendance/leave` + - `GET /api/v1/sessions/attendance/:sessionKey/stats` + +## Step 4: Add NestJS module wiring +- Create `BackendAcademy/src/sessions/sessions.module.ts` and register TypeORM entity + providers. + +## Step 5: Wire into app +- Update `BackendAcademy/src/app.module.ts` to import `SessionsModule`. + +## Step 6: Verify build +- Run `npm run build` (or `npm test`) inside `BackendAcademy`. + +## Progress +- Step 1-5 implemented (sessions module + attendance tracking). + +