diff --git a/BackendAcademy/src/app.module.ts b/BackendAcademy/src/app.module.ts index caade999b..e95c2b6c9 100644 --- a/BackendAcademy/src/app.module.ts +++ b/BackendAcademy/src/app.module.ts @@ -20,16 +20,6 @@ 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 { JobsModule } from './jobs/jobs.module'; -import { LoggingModule } from './logging/logging.module'; -import { ProgressModule } from './courses/progress/progress.module'; -import { AppConfigModule } from './config/config.module'; -import { ContractsModule } from './contracts/contracts.module'; -import { AssetsModule } from './assets/assets.module'; -import { PathfindingModule } from './pathfinding/pathfinding.module'; -import { MonitoringModule } from './monitoring/monitoring.module'; -import { SearchModule } from './search/search.module'; -import { PaymentsModule } from './payments/payments.module'; import { SessionsModule } from './sessions/sessions.module'; @Module({ 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 index c164b2c0c..e02a7ba79 100644 --- a/BackendAcademy/src/sessions/sessions.module.ts +++ b/BackendAcademy/src/sessions/sessions.module.ts @@ -1,10 +1,14 @@ import { Module } from '@nestjs/common'; -import { OfficeHoursController } from './office-hours.controller'; -import { OfficeHoursService } from './office-hours.service'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { AttendanceEntity } from './attendance.entity'; +import { AttendanceController } from './attendance.controller'; +import { AttendanceService } from './attendance.service'; @Module({ - controllers: [OfficeHoursController], - providers: [OfficeHoursService], - exports: [OfficeHoursService], + 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). + +