VirtualClass BackEnd

This commit is contained in:
Sedat Öztürk 2025-08-25 21:01:57 +03:00
parent 45b763776d
commit 7d37a07e05
15 changed files with 8467 additions and 0 deletions

View file

@ -0,0 +1,14 @@
using System;
namespace Kurs.Platform.VirtualClassrooms;
public class ChatMessageDto
{
public Guid Id { get; set; }
public Guid SessionId { get; set; }
public Guid SenderId { get; set; }
public string SenderName { get; set; }
public string Message { get; set; }
public DateTime Timestamp { get; set; }
public bool IsTeacher { get; set; }
}

View file

@ -0,0 +1,58 @@
using System;
using Volo.Abp.Application.Dtos;
namespace Kurs.Platform.VirtualClassrooms;
public class ClassSessionDto : FullAuditedEntityDto<Guid>
{
public string Name { get; set; }
public string Description { get; set; }
public string Subject { get; set; }
public Guid TeacherId { get; set; }
public string TeacherName { get; set; }
public DateTime ScheduledStartTime { get; set; }
public DateTime? ActualStartTime { get; set; }
public DateTime? EndTime { get; set; }
public int Duration { get; set; }
public int MaxParticipants { get; set; }
public bool IsActive { get; set; }
public bool IsScheduled { get; set; }
public int ParticipantCount { get; set; }
public bool CanJoin { get; set; }
}
public class CreateClassSessionDto
{
public string Name { get; set; }
public string Description { get; set; }
public string Subject { get; set; }
public DateTime ScheduledStartTime { get; set; }
public int Duration { get; set; } = 60;
public int MaxParticipants { get; set; } = 30;
}
public class UpdateClassSessionDto
{
public string Name { get; set; }
public string Description { get; set; }
public string Subject { get; set; }
public DateTime ScheduledStartTime { get; set; }
public int Duration { get; set; }
public int MaxParticipants { get; set; }
}
public class GetClassSessionListDto : PagedAndSortedResultRequestDto
{
public bool? IsActive { get; set; }
public Guid? TeacherId { get; set; }
}
public class AttendanceRecordDto : EntityDto<Guid>
{
public Guid SessionId { get; set; }
public Guid StudentId { get; set; }
public string StudentName { get; set; }
public DateTime JoinTime { get; set; }
public DateTime? LeaveTime { get; set; }
public int TotalDurationMinutes { get; set; }
}

View file

@ -0,0 +1,21 @@
using System;
using System.Collections.Generic;
using System.Threading.Tasks;
using Volo.Abp.Application.Dtos;
using Volo.Abp.Application.Services;
namespace Kurs.Platform.VirtualClassrooms;
public interface IVirtualClassAppService : IApplicationService
{
Task<ClassSessionDto> CreateAsync(CreateClassSessionDto input);
Task<PagedResultDto<ClassSessionDto>> GetListAsync(GetClassSessionListDto input);
Task<ClassSessionDto> GetAsync(Guid id);
Task<ClassSessionDto> UpdateAsync(Guid id, UpdateClassSessionDto input);
Task DeleteAsync(Guid id);
Task<ClassSessionDto> StartClassAsync(Guid id);
Task EndClassAsync(Guid id);
Task<ClassSessionDto> JoinClassAsync(Guid id);
Task LeaveClassAsync(Guid id);
Task<List<AttendanceRecordDto>> GetAttendanceAsync(Guid sessionId);
}

View file

@ -0,0 +1,16 @@
using System;
namespace Kurs.Platform.VirtualClassrooms;
public class ParticipantDto
{
public Guid Id { get; set; }
public Guid SessionId { get; set; }
public Guid UserId { get; set; }
public string UserName { get; set; }
public string UserEmail { get; set; }
public bool IsTeacher { get; set; }
public bool IsAudioMuted { get; set; }
public bool IsVideoMuted { get; set; }
public DateTime JoinTime { get; set; }
}

View file

@ -0,0 +1,265 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using Kurs.Platform.Entities;
using Microsoft.AspNetCore.Authorization;
using Volo.Abp.Application.Dtos;
using Volo.Abp.Domain.Repositories;
namespace Kurs.Platform.VirtualClassrooms;
[Authorize]
public class VirtualClassAppService : PlatformAppService, IVirtualClassAppService
{
private readonly IRepository<ClassSession, Guid> _classSessionRepository;
private readonly IRepository<Participant, Guid> _participantRepository;
private readonly IRepository<AttendanceRecord, Guid> _attendanceRepository;
public VirtualClassAppService(
IRepository<ClassSession, Guid> classSessionRepository,
IRepository<Participant, Guid> participantRepository,
IRepository<AttendanceRecord, Guid> attendanceRepository)
{
_classSessionRepository = classSessionRepository;
_participantRepository = participantRepository;
_attendanceRepository = attendanceRepository;
}
public async Task<ClassSessionDto> CreateAsync(CreateClassSessionDto input)
{
var classSession = new ClassSession(
GuidGenerator.Create(),
input.Name,
input.Description,
input.Subject,
CurrentUser.Id,
CurrentUser.Name,
input.ScheduledStartTime,
input.Duration,
input.MaxParticipants
);
await _classSessionRepository.InsertAsync(classSession);
await CurrentUnitOfWork.SaveChangesAsync();
return ObjectMapper.Map<ClassSession, ClassSessionDto>(classSession);
}
public async Task<PagedResultDto<ClassSessionDto>> GetListAsync(GetClassSessionListDto input)
{
var query = await _classSessionRepository.GetQueryableAsync();
if (input.IsActive.HasValue)
{
query = query.Where(x => x.IsActive == input.IsActive.Value);
}
if (input.TeacherId.HasValue)
{
query = query.Where(x => x.TeacherId == input.TeacherId.Value);
}
var totalCount = query.Count();
var items = query
.OrderBy(x => x.ScheduledStartTime)
.Skip(input.SkipCount)
.Take(input.MaxResultCount)
.ToList();
return new PagedResultDto<ClassSessionDto>(
totalCount,
ObjectMapper.Map<List<ClassSession>, List<ClassSessionDto>>(items)
);
}
public async Task<ClassSessionDto> GetAsync(Guid id)
{
var classSession = await _classSessionRepository.GetAsync(id);
return ObjectMapper.Map<ClassSession, ClassSessionDto>(classSession);
}
public async Task<ClassSessionDto> UpdateAsync(Guid id, UpdateClassSessionDto input)
{
var classSession = await _classSessionRepository.GetAsync(id);
if (classSession.TeacherId != CurrentUser.Id)
{
throw new UnauthorizedAccessException("Only the teacher can update this class");
}
if (classSession.IsActive)
{
throw new InvalidOperationException("Cannot update an active class");
}
classSession.Name = input.Name;
classSession.Description = input.Description;
classSession.Subject = input.Subject;
classSession.ScheduledStartTime = input.ScheduledStartTime;
classSession.Duration = input.Duration;
classSession.MaxParticipants = input.MaxParticipants;
await _classSessionRepository.UpdateAsync(classSession);
return ObjectMapper.Map<ClassSession, ClassSessionDto>(classSession);
}
public async Task DeleteAsync(Guid id)
{
var classSession = await _classSessionRepository.GetAsync(id);
if (classSession.TeacherId != CurrentUser.Id)
{
throw new UnauthorizedAccessException("Only the teacher can delete this class");
}
if (classSession.IsActive)
{
throw new InvalidOperationException("Cannot delete an active class");
}
await _classSessionRepository.DeleteAsync(id);
}
public async Task<ClassSessionDto> StartClassAsync(Guid id)
{
var classSession = await _classSessionRepository.GetAsync(id);
if (classSession.TeacherId != CurrentUser.Id)
{
throw new UnauthorizedAccessException("Only the teacher can start this class");
}
if (!classSession.CanJoin())
{
throw new InvalidOperationException("Class cannot be started at this time");
}
classSession.StartClass();
await _classSessionRepository.UpdateAsync(classSession);
return ObjectMapper.Map<ClassSession, ClassSessionDto>(classSession);
}
public async Task EndClassAsync(Guid id)
{
var classSession = await _classSessionRepository.GetAsync(id);
if (classSession.TeacherId != CurrentUser.Id)
{
throw new UnauthorizedAccessException("Only the teacher can end this class");
}
classSession.EndClass();
await _classSessionRepository.UpdateAsync(classSession);
// Update attendance records
var activeAttendances = await _attendanceRepository.GetListAsync(
x => x.SessionId == id && x.LeaveTime == null
);
foreach (var attendance in activeAttendances)
{
attendance.LeaveTime = DateTime.UtcNow;
attendance.CalculateDuration();
await _attendanceRepository.UpdateAsync(attendance);
}
}
public async Task<ClassSessionDto> JoinClassAsync(Guid id)
{
var classSession = await _classSessionRepository.GetAsync(id);
if (!classSession.CanJoin())
{
throw new InvalidOperationException("Cannot join this class at this time");
}
if (classSession.ParticipantCount >= classSession.MaxParticipants)
{
throw new InvalidOperationException("Class is full");
}
// Check if user is already in the class
var existingParticipant = await _participantRepository.FirstOrDefaultAsync(
x => x.SessionId == id && x.UserId == CurrentUser.Id
);
if (existingParticipant == null)
{
// Add participant
var participant = new Participant(
GuidGenerator.Create(),
id,
CurrentUser.Id,
CurrentUser.Name,
CurrentUser.Email,
false // isTeacher
);
await _participantRepository.InsertAsync(participant);
// Create attendance record
var attendance = new AttendanceRecord(
GuidGenerator.Create(),
id,
CurrentUser.Id,
CurrentUser.Name,
DateTime.UtcNow
);
await _attendanceRepository.InsertAsync(attendance);
// Update participant count
classSession.ParticipantCount++;
await _classSessionRepository.UpdateAsync(classSession);
}
return ObjectMapper.Map<ClassSession, ClassSessionDto>(classSession);
}
public async Task LeaveClassAsync(Guid id)
{
var participant = await _participantRepository.FirstOrDefaultAsync(
x => x.SessionId == id && x.UserId == CurrentUser.Id
);
if (participant != null)
{
await _participantRepository.DeleteAsync(participant);
// Update attendance record
var attendance = await _attendanceRepository.FirstOrDefaultAsync(
x => x.SessionId == id && x.StudentId == CurrentUser.Id && x.LeaveTime == null
);
if (attendance != null)
{
attendance.LeaveTime = DateTime.UtcNow;
attendance.CalculateDuration();
await _attendanceRepository.UpdateAsync(attendance);
}
// Update participant count
var classSession = await _classSessionRepository.GetAsync(id);
classSession.ParticipantCount = Math.Max(0, classSession.ParticipantCount - 1);
await _classSessionRepository.UpdateAsync(classSession);
}
}
public async Task<List<AttendanceRecordDto>> GetAttendanceAsync(Guid sessionId)
{
var classSession = await _classSessionRepository.GetAsync(sessionId);
if (classSession.TeacherId != CurrentUser.Id)
{
throw new UnauthorizedAccessException("Only the teacher can view attendance");
}
var attendanceRecords = await _attendanceRepository.GetListAsync(
x => x.SessionId == sessionId
);
return ObjectMapper.Map<List<AttendanceRecord>, List<AttendanceRecordDto>>(attendanceRecords);
}
}

View file

@ -0,0 +1,20 @@
using AutoMapper;
using Kurs.Platform.Entities;
namespace Kurs.Platform.VirtualClassrooms;
public class VirtualClassAutoMapperProfile : Profile
{
public VirtualClassAutoMapperProfile()
{
CreateMap<ClassSession, ClassSessionDto>()
.ForMember(dest => dest.CanJoin, opt => opt.MapFrom(src => src.CanJoin()));
CreateMap<CreateClassSessionDto, ClassSession>();
CreateMap<UpdateClassSessionDto, ClassSession>();
CreateMap<AttendanceRecord, AttendanceRecordDto>();
CreateMap<Participant, ParticipantDto>();
CreateMap<ChatMessage, ChatMessageDto>();
}
}

View file

@ -0,0 +1,50 @@
using System;
using Volo.Abp.Domain.Entities.Auditing;
namespace Kurs.Platform.Entities;
public class AttendanceRecord : FullAuditedEntity<Guid>
{
public Guid SessionId { get; set; }
public Guid? StudentId { get; set; }
public string StudentName { get; set; }
public DateTime JoinTime { get; set; }
public DateTime? LeaveTime { get; set; }
public int TotalDurationMinutes { get; set; }
// Navigation properties
public virtual ClassSession Session { get; set; }
protected AttendanceRecord()
{
}
public AttendanceRecord(
Guid id,
Guid sessionId,
Guid? studentId,
string studentName,
DateTime joinTime
) : base(id)
{
SessionId = sessionId;
StudentId = studentId;
StudentName = studentName;
JoinTime = joinTime;
TotalDurationMinutes = 0;
}
public void CalculateDuration()
{
if (LeaveTime.HasValue)
{
var duration = LeaveTime.Value - JoinTime;
TotalDurationMinutes = (int)duration.TotalMinutes;
}
else
{
var duration = DateTime.UtcNow - JoinTime;
TotalDurationMinutes = (int)duration.TotalMinutes;
}
}
}

View file

@ -0,0 +1,38 @@
using System;
using Volo.Abp.Domain.Entities.Auditing;
namespace Kurs.Platform.Entities;
public class ChatMessage : FullAuditedEntity<Guid>
{
public Guid SessionId { get; set; }
public Guid SenderId { get; set; }
public string SenderName { get; set; }
public string Message { get; set; }
public DateTime Timestamp { get; set; }
public bool IsTeacher { get; set; }
// Navigation properties
public virtual ClassSession Session { get; set; }
protected ChatMessage()
{
}
public ChatMessage(
Guid id,
Guid sessionId,
Guid senderId,
string senderName,
string message,
bool isTeacher
) : base(id)
{
SessionId = sessionId;
SenderId = senderId;
SenderName = senderName;
Message = message;
IsTeacher = isTeacher;
Timestamp = DateTime.UtcNow;
}
}

View file

@ -0,0 +1,89 @@
using System;
using System.Collections.Generic;
using Volo.Abp.Domain.Entities.Auditing;
namespace Kurs.Platform.Entities;
public class ClassSession : FullAuditedEntity<Guid>
{
public string Name { get; set; }
public string Description { get; set; }
public string Subject { get; set; }
public Guid? TeacherId { get; set; }
public string TeacherName { get; set; }
public DateTime ScheduledStartTime { get; set; }
public DateTime? ActualStartTime { get; set; }
public DateTime? EndTime { get; set; }
public int Duration { get; set; } // minutes
public int MaxParticipants { get; set; }
public bool IsActive { get; set; }
public bool IsScheduled { get; set; }
public int ParticipantCount { get; set; }
public virtual ICollection<Participant> Participants { get; set; }
public virtual ICollection<AttendanceRecord> AttendanceRecords { get; set; }
public virtual ICollection<ChatMessage> ChatMessages { get; set; }
protected ClassSession()
{
Participants = new HashSet<Participant>();
AttendanceRecords = new HashSet<AttendanceRecord>();
ChatMessages = new HashSet<ChatMessage>();
}
public ClassSession(
Guid id,
string name,
string description,
string subject,
Guid? teacherId,
string teacherName,
DateTime scheduledStartTime,
int duration,
int maxParticipants
) : base(id)
{
Name = name;
Description = description;
Subject = subject;
TeacherId = teacherId;
TeacherName = teacherName;
ScheduledStartTime = scheduledStartTime;
Duration = duration;
MaxParticipants = maxParticipants;
IsActive = false;
IsScheduled = true;
ParticipantCount = 0;
Participants = new HashSet<Participant>();
AttendanceRecords = new HashSet<AttendanceRecord>();
ChatMessages = new HashSet<ChatMessage>();
}
public void StartClass()
{
if (IsActive)
throw new InvalidOperationException("Class is already active");
IsActive = true;
ActualStartTime = DateTime.UtcNow;
}
public void EndClass()
{
if (!IsActive)
throw new InvalidOperationException("Class is not active");
IsActive = false;
EndTime = DateTime.UtcNow;
}
public bool CanJoin()
{
var now = DateTime.UtcNow;
var tenMinutesBefore = ScheduledStartTime.AddMinutes(-10);
var twoHoursAfter = ScheduledStartTime.AddHours(2);
return now >= tenMinutesBefore && now <= twoHoursAfter && ParticipantCount < MaxParticipants;
}
}

View file

@ -0,0 +1,68 @@
using System;
using Volo.Abp.Domain.Entities.Auditing;
namespace Kurs.Platform.Entities;
public class Participant : FullAuditedEntity<Guid>
{
public Guid SessionId { get; set; }
public Guid? UserId { get; set; }
public string UserName { get; set; }
public string UserEmail { get; set; }
public bool IsTeacher { get; set; }
public bool IsAudioMuted { get; set; }
public bool IsVideoMuted { get; set; }
public DateTime JoinTime { get; set; }
public string ConnectionId { get; set; }
// Navigation properties
public virtual ClassSession Session { get; set; }
protected Participant()
{
}
public Participant(
Guid id,
Guid sessionId,
Guid? userId,
string userName,
string userEmail,
bool isTeacher
) : base(id)
{
SessionId = sessionId;
UserId = userId;
UserName = userName;
UserEmail = userEmail;
IsTeacher = isTeacher;
IsAudioMuted = false;
IsVideoMuted = false;
JoinTime = DateTime.UtcNow;
}
public void MuteAudio()
{
IsAudioMuted = true;
}
public void UnmuteAudio()
{
IsAudioMuted = false;
}
public void MuteVideo()
{
IsVideoMuted = true;
}
public void UnmuteVideo()
{
IsVideoMuted = false;
}
public void UpdateConnectionId(string connectionId)
{
ConnectionId = connectionId;
}
}

View file

@ -97,6 +97,11 @@ public class PlatformDbContext :
public DbSet<Demo> Demos { get; set; } public DbSet<Demo> Demos { get; set; }
public DbSet<Service> Services { get; set; } public DbSet<Service> Services { get; set; }
public DbSet<ClassSession> ClassSessions { get; set; }
public DbSet<Participant> Participants { get; set; }
public DbSet<AttendanceRecord> AttendanceRecords { get; set; }
public DbSet<ChatMessage> ChatMessages { get; set; }
#region Entities from the modules #region Entities from the modules
/* Notice: We only implemented IIdentityDbContext and ITenantManagementDbContext /* Notice: We only implemented IIdentityDbContext and ITenantManagementDbContext
@ -875,5 +880,79 @@ public class PlatformDbContext :
b.Property(x => x.WorkHoursJson).HasColumnType("nvarchar(max)"); b.Property(x => x.WorkHoursJson).HasColumnType("nvarchar(max)");
b.Property(x => x.MapJson).HasColumnType("nvarchar(max)"); b.Property(x => x.MapJson).HasColumnType("nvarchar(max)");
}); });
// ClassSession
builder.Entity<ClassSession>(b =>
{
b.ToTable(PlatformConsts.DbTablePrefix + nameof(ClassSession), PlatformConsts.DbSchema);
b.ConfigureByConvention();
b.Property(x => x.Name).IsRequired().HasMaxLength(200);
b.Property(x => x.Description).HasMaxLength(1000);
b.Property(x => x.Subject).HasMaxLength(100);
b.Property(x => x.TeacherName).IsRequired().HasMaxLength(100);
b.HasIndex(x => x.TeacherId);
b.HasIndex(x => x.ScheduledStartTime);
b.HasIndex(x => x.IsActive);
// Relationships
b.HasMany(x => x.Participants)
.WithOne(x => x.Session)
.HasForeignKey(x => x.SessionId)
.OnDelete(DeleteBehavior.Cascade);
b.HasMany(x => x.AttendanceRecords)
.WithOne(x => x.Session)
.HasForeignKey(x => x.SessionId)
.OnDelete(DeleteBehavior.Cascade);
b.HasMany(x => x.ChatMessages)
.WithOne(x => x.Session)
.HasForeignKey(x => x.SessionId)
.OnDelete(DeleteBehavior.Cascade);
});
// Participant
builder.Entity<Participant>(b =>
{
b.ToTable(PlatformConsts.DbTablePrefix + nameof(Participant), PlatformConsts.DbSchema);
b.ConfigureByConvention();
b.Property(x => x.UserName).IsRequired().HasMaxLength(100);
b.Property(x => x.UserEmail).HasMaxLength(200);
b.Property(x => x.ConnectionId).HasMaxLength(100);
b.HasIndex(x => x.SessionId);
b.HasIndex(x => x.UserId);
b.HasIndex(x => new { x.SessionId, x.UserId }).IsUnique();
});
// AttendanceRecord
builder.Entity<AttendanceRecord>(b =>
{
b.ToTable(PlatformConsts.DbTablePrefix + nameof(AttendanceRecord), PlatformConsts.DbSchema);
b.ConfigureByConvention();
b.Property(x => x.StudentName).IsRequired().HasMaxLength(100);
b.HasIndex(x => x.SessionId);
b.HasIndex(x => x.StudentId);
b.HasIndex(x => x.JoinTime);
});
// ChatMessage
builder.Entity<ChatMessage>(b =>
{
b.ToTable(PlatformConsts.DbTablePrefix + nameof(ChatMessage), PlatformConsts.DbSchema);
b.ConfigureByConvention();
b.Property(x => x.SenderName).IsRequired().HasMaxLength(100);
b.Property(x => x.Message).IsRequired().HasMaxLength(2000);
b.HasIndex(x => x.SessionId);
b.HasIndex(x => x.SenderId);
b.HasIndex(x => x.Timestamp);
});
} }
} }

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,217 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Kurs.Platform.Migrations
{
/// <inheritdoc />
public partial class VirtualClass : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "PClassSession",
columns: table => new
{
Id = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
Name = table.Column<string>(type: "nvarchar(200)", maxLength: 200, nullable: false),
Description = table.Column<string>(type: "nvarchar(1000)", maxLength: 1000, nullable: true),
Subject = table.Column<string>(type: "nvarchar(100)", maxLength: 100, nullable: true),
TeacherId = table.Column<Guid>(type: "uniqueidentifier", nullable: true),
TeacherName = table.Column<string>(type: "nvarchar(100)", maxLength: 100, nullable: false),
ScheduledStartTime = table.Column<DateTime>(type: "datetime2", nullable: false),
ActualStartTime = table.Column<DateTime>(type: "datetime2", nullable: true),
EndTime = table.Column<DateTime>(type: "datetime2", nullable: true),
Duration = table.Column<int>(type: "int", nullable: false),
MaxParticipants = table.Column<int>(type: "int", nullable: false),
IsActive = table.Column<bool>(type: "bit", nullable: false),
IsScheduled = table.Column<bool>(type: "bit", nullable: false),
ParticipantCount = table.Column<int>(type: "int", nullable: false),
CreationTime = table.Column<DateTime>(type: "datetime2", nullable: false),
CreatorId = table.Column<Guid>(type: "uniqueidentifier", nullable: true),
LastModificationTime = table.Column<DateTime>(type: "datetime2", nullable: true),
LastModifierId = table.Column<Guid>(type: "uniqueidentifier", nullable: true),
IsDeleted = table.Column<bool>(type: "bit", nullable: false, defaultValue: false),
DeleterId = table.Column<Guid>(type: "uniqueidentifier", nullable: true),
DeletionTime = table.Column<DateTime>(type: "datetime2", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_PClassSession", x => x.Id);
});
migrationBuilder.CreateTable(
name: "PAttendanceRecord",
columns: table => new
{
Id = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
SessionId = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
StudentId = table.Column<Guid>(type: "uniqueidentifier", nullable: true),
StudentName = table.Column<string>(type: "nvarchar(100)", maxLength: 100, nullable: false),
JoinTime = table.Column<DateTime>(type: "datetime2", nullable: false),
LeaveTime = table.Column<DateTime>(type: "datetime2", nullable: true),
TotalDurationMinutes = table.Column<int>(type: "int", nullable: false),
CreationTime = table.Column<DateTime>(type: "datetime2", nullable: false),
CreatorId = table.Column<Guid>(type: "uniqueidentifier", nullable: true),
LastModificationTime = table.Column<DateTime>(type: "datetime2", nullable: true),
LastModifierId = table.Column<Guid>(type: "uniqueidentifier", nullable: true),
IsDeleted = table.Column<bool>(type: "bit", nullable: false, defaultValue: false),
DeleterId = table.Column<Guid>(type: "uniqueidentifier", nullable: true),
DeletionTime = table.Column<DateTime>(type: "datetime2", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_PAttendanceRecord", x => x.Id);
table.ForeignKey(
name: "FK_PAttendanceRecord_PClassSession_SessionId",
column: x => x.SessionId,
principalTable: "PClassSession",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "PChatMessage",
columns: table => new
{
Id = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
SessionId = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
SenderId = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
SenderName = table.Column<string>(type: "nvarchar(100)", maxLength: 100, nullable: false),
Message = table.Column<string>(type: "nvarchar(2000)", maxLength: 2000, nullable: false),
Timestamp = table.Column<DateTime>(type: "datetime2", nullable: false),
IsTeacher = table.Column<bool>(type: "bit", nullable: false),
CreationTime = table.Column<DateTime>(type: "datetime2", nullable: false),
CreatorId = table.Column<Guid>(type: "uniqueidentifier", nullable: true),
LastModificationTime = table.Column<DateTime>(type: "datetime2", nullable: true),
LastModifierId = table.Column<Guid>(type: "uniqueidentifier", nullable: true),
IsDeleted = table.Column<bool>(type: "bit", nullable: false, defaultValue: false),
DeleterId = table.Column<Guid>(type: "uniqueidentifier", nullable: true),
DeletionTime = table.Column<DateTime>(type: "datetime2", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_PChatMessage", x => x.Id);
table.ForeignKey(
name: "FK_PChatMessage_PClassSession_SessionId",
column: x => x.SessionId,
principalTable: "PClassSession",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "PParticipant",
columns: table => new
{
Id = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
SessionId = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
UserId = table.Column<Guid>(type: "uniqueidentifier", nullable: true),
UserName = table.Column<string>(type: "nvarchar(100)", maxLength: 100, nullable: false),
UserEmail = table.Column<string>(type: "nvarchar(200)", maxLength: 200, nullable: true),
IsTeacher = table.Column<bool>(type: "bit", nullable: false),
IsAudioMuted = table.Column<bool>(type: "bit", nullable: false),
IsVideoMuted = table.Column<bool>(type: "bit", nullable: false),
JoinTime = table.Column<DateTime>(type: "datetime2", nullable: false),
ConnectionId = table.Column<string>(type: "nvarchar(100)", maxLength: 100, nullable: true),
CreationTime = table.Column<DateTime>(type: "datetime2", nullable: false),
CreatorId = table.Column<Guid>(type: "uniqueidentifier", nullable: true),
LastModificationTime = table.Column<DateTime>(type: "datetime2", nullable: true),
LastModifierId = table.Column<Guid>(type: "uniqueidentifier", nullable: true),
IsDeleted = table.Column<bool>(type: "bit", nullable: false, defaultValue: false),
DeleterId = table.Column<Guid>(type: "uniqueidentifier", nullable: true),
DeletionTime = table.Column<DateTime>(type: "datetime2", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_PParticipant", x => x.Id);
table.ForeignKey(
name: "FK_PParticipant_PClassSession_SessionId",
column: x => x.SessionId,
principalTable: "PClassSession",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "IX_PAttendanceRecord_JoinTime",
table: "PAttendanceRecord",
column: "JoinTime");
migrationBuilder.CreateIndex(
name: "IX_PAttendanceRecord_SessionId",
table: "PAttendanceRecord",
column: "SessionId");
migrationBuilder.CreateIndex(
name: "IX_PAttendanceRecord_StudentId",
table: "PAttendanceRecord",
column: "StudentId");
migrationBuilder.CreateIndex(
name: "IX_PChatMessage_SenderId",
table: "PChatMessage",
column: "SenderId");
migrationBuilder.CreateIndex(
name: "IX_PChatMessage_SessionId",
table: "PChatMessage",
column: "SessionId");
migrationBuilder.CreateIndex(
name: "IX_PChatMessage_Timestamp",
table: "PChatMessage",
column: "Timestamp");
migrationBuilder.CreateIndex(
name: "IX_PClassSession_IsActive",
table: "PClassSession",
column: "IsActive");
migrationBuilder.CreateIndex(
name: "IX_PClassSession_ScheduledStartTime",
table: "PClassSession",
column: "ScheduledStartTime");
migrationBuilder.CreateIndex(
name: "IX_PClassSession_TeacherId",
table: "PClassSession",
column: "TeacherId");
migrationBuilder.CreateIndex(
name: "IX_PParticipant_SessionId",
table: "PParticipant",
column: "SessionId");
migrationBuilder.CreateIndex(
name: "IX_PParticipant_SessionId_UserId",
table: "PParticipant",
columns: new[] { "SessionId", "UserId" },
unique: true,
filter: "[UserId] IS NOT NULL");
migrationBuilder.CreateIndex(
name: "IX_PParticipant_UserId",
table: "PParticipant",
column: "UserId");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "PAttendanceRecord");
migrationBuilder.DropTable(
name: "PChatMessage");
migrationBuilder.DropTable(
name: "PParticipant");
migrationBuilder.DropTable(
name: "PClassSession");
}
}
}

View file

@ -849,6 +849,72 @@ namespace Kurs.Platform.Migrations
b.ToTable("PApiMigration", (string)null); b.ToTable("PApiMigration", (string)null);
}); });
modelBuilder.Entity("Kurs.Platform.Entities.AttendanceRecord", b =>
{
b.Property<Guid>("Id")
.HasColumnType("uniqueidentifier");
b.Property<DateTime>("CreationTime")
.HasColumnType("datetime2")
.HasColumnName("CreationTime");
b.Property<Guid?>("CreatorId")
.HasColumnType("uniqueidentifier")
.HasColumnName("CreatorId");
b.Property<Guid?>("DeleterId")
.HasColumnType("uniqueidentifier")
.HasColumnName("DeleterId");
b.Property<DateTime?>("DeletionTime")
.HasColumnType("datetime2")
.HasColumnName("DeletionTime");
b.Property<bool>("IsDeleted")
.ValueGeneratedOnAdd()
.HasColumnType("bit")
.HasDefaultValue(false)
.HasColumnName("IsDeleted");
b.Property<DateTime>("JoinTime")
.HasColumnType("datetime2");
b.Property<DateTime?>("LastModificationTime")
.HasColumnType("datetime2")
.HasColumnName("LastModificationTime");
b.Property<Guid?>("LastModifierId")
.HasColumnType("uniqueidentifier")
.HasColumnName("LastModifierId");
b.Property<DateTime?>("LeaveTime")
.HasColumnType("datetime2");
b.Property<Guid>("SessionId")
.HasColumnType("uniqueidentifier");
b.Property<Guid?>("StudentId")
.HasColumnType("uniqueidentifier");
b.Property<string>("StudentName")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("nvarchar(100)");
b.Property<int>("TotalDurationMinutes")
.HasColumnType("int");
b.HasKey("Id");
b.HasIndex("JoinTime");
b.HasIndex("SessionId");
b.HasIndex("StudentId");
b.ToTable("PAttendanceRecord", (string)null);
});
modelBuilder.Entity("Kurs.Platform.Entities.BackgroundWorker", b => modelBuilder.Entity("Kurs.Platform.Entities.BackgroundWorker", b =>
{ {
b.Property<Guid>("Id") b.Property<Guid>("Id")
@ -1494,6 +1560,74 @@ namespace Kurs.Platform.Migrations
b.ToTable("PChart", (string)null); b.ToTable("PChart", (string)null);
}); });
modelBuilder.Entity("Kurs.Platform.Entities.ChatMessage", b =>
{
b.Property<Guid>("Id")
.HasColumnType("uniqueidentifier");
b.Property<DateTime>("CreationTime")
.HasColumnType("datetime2")
.HasColumnName("CreationTime");
b.Property<Guid?>("CreatorId")
.HasColumnType("uniqueidentifier")
.HasColumnName("CreatorId");
b.Property<Guid?>("DeleterId")
.HasColumnType("uniqueidentifier")
.HasColumnName("DeleterId");
b.Property<DateTime?>("DeletionTime")
.HasColumnType("datetime2")
.HasColumnName("DeletionTime");
b.Property<bool>("IsDeleted")
.ValueGeneratedOnAdd()
.HasColumnType("bit")
.HasDefaultValue(false)
.HasColumnName("IsDeleted");
b.Property<bool>("IsTeacher")
.HasColumnType("bit");
b.Property<DateTime?>("LastModificationTime")
.HasColumnType("datetime2")
.HasColumnName("LastModificationTime");
b.Property<Guid?>("LastModifierId")
.HasColumnType("uniqueidentifier")
.HasColumnName("LastModifierId");
b.Property<string>("Message")
.IsRequired()
.HasMaxLength(2000)
.HasColumnType("nvarchar(2000)");
b.Property<Guid>("SenderId")
.HasColumnType("uniqueidentifier");
b.Property<string>("SenderName")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("nvarchar(100)");
b.Property<Guid>("SessionId")
.HasColumnType("uniqueidentifier");
b.Property<DateTime>("Timestamp")
.HasColumnType("datetime2");
b.HasKey("Id");
b.HasIndex("SenderId");
b.HasIndex("SessionId");
b.HasIndex("Timestamp");
b.ToTable("PChatMessage", (string)null);
});
modelBuilder.Entity("Kurs.Platform.Entities.City", b => modelBuilder.Entity("Kurs.Platform.Entities.City", b =>
{ {
b.Property<Guid>("Id") b.Property<Guid>("Id")
@ -1556,6 +1690,97 @@ namespace Kurs.Platform.Migrations
b.ToTable("PCity", (string)null); b.ToTable("PCity", (string)null);
}); });
modelBuilder.Entity("Kurs.Platform.Entities.ClassSession", b =>
{
b.Property<Guid>("Id")
.HasColumnType("uniqueidentifier");
b.Property<DateTime?>("ActualStartTime")
.HasColumnType("datetime2");
b.Property<DateTime>("CreationTime")
.HasColumnType("datetime2")
.HasColumnName("CreationTime");
b.Property<Guid?>("CreatorId")
.HasColumnType("uniqueidentifier")
.HasColumnName("CreatorId");
b.Property<Guid?>("DeleterId")
.HasColumnType("uniqueidentifier")
.HasColumnName("DeleterId");
b.Property<DateTime?>("DeletionTime")
.HasColumnType("datetime2")
.HasColumnName("DeletionTime");
b.Property<string>("Description")
.HasMaxLength(1000)
.HasColumnType("nvarchar(1000)");
b.Property<int>("Duration")
.HasColumnType("int");
b.Property<DateTime?>("EndTime")
.HasColumnType("datetime2");
b.Property<bool>("IsActive")
.HasColumnType("bit");
b.Property<bool>("IsDeleted")
.ValueGeneratedOnAdd()
.HasColumnType("bit")
.HasDefaultValue(false)
.HasColumnName("IsDeleted");
b.Property<bool>("IsScheduled")
.HasColumnType("bit");
b.Property<DateTime?>("LastModificationTime")
.HasColumnType("datetime2")
.HasColumnName("LastModificationTime");
b.Property<Guid?>("LastModifierId")
.HasColumnType("uniqueidentifier")
.HasColumnName("LastModifierId");
b.Property<int>("MaxParticipants")
.HasColumnType("int");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("nvarchar(200)");
b.Property<int>("ParticipantCount")
.HasColumnType("int");
b.Property<DateTime>("ScheduledStartTime")
.HasColumnType("datetime2");
b.Property<string>("Subject")
.HasMaxLength(100)
.HasColumnType("nvarchar(100)");
b.Property<Guid?>("TeacherId")
.HasColumnType("uniqueidentifier");
b.Property<string>("TeacherName")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("nvarchar(100)");
b.HasKey("Id");
b.HasIndex("IsActive");
b.HasIndex("ScheduledStartTime");
b.HasIndex("TeacherId");
b.ToTable("PClassSession", (string)null);
});
modelBuilder.Entity("Kurs.Platform.Entities.Contact", b => modelBuilder.Entity("Kurs.Platform.Entities.Contact", b =>
{ {
b.Property<Guid>("Id") b.Property<Guid>("Id")
@ -3049,6 +3274,85 @@ namespace Kurs.Platform.Migrations
b.ToTable("PMenu", (string)null); b.ToTable("PMenu", (string)null);
}); });
modelBuilder.Entity("Kurs.Platform.Entities.Participant", b =>
{
b.Property<Guid>("Id")
.HasColumnType("uniqueidentifier");
b.Property<string>("ConnectionId")
.HasMaxLength(100)
.HasColumnType("nvarchar(100)");
b.Property<DateTime>("CreationTime")
.HasColumnType("datetime2")
.HasColumnName("CreationTime");
b.Property<Guid?>("CreatorId")
.HasColumnType("uniqueidentifier")
.HasColumnName("CreatorId");
b.Property<Guid?>("DeleterId")
.HasColumnType("uniqueidentifier")
.HasColumnName("DeleterId");
b.Property<DateTime?>("DeletionTime")
.HasColumnType("datetime2")
.HasColumnName("DeletionTime");
b.Property<bool>("IsAudioMuted")
.HasColumnType("bit");
b.Property<bool>("IsDeleted")
.ValueGeneratedOnAdd()
.HasColumnType("bit")
.HasDefaultValue(false)
.HasColumnName("IsDeleted");
b.Property<bool>("IsTeacher")
.HasColumnType("bit");
b.Property<bool>("IsVideoMuted")
.HasColumnType("bit");
b.Property<DateTime>("JoinTime")
.HasColumnType("datetime2");
b.Property<DateTime?>("LastModificationTime")
.HasColumnType("datetime2")
.HasColumnName("LastModificationTime");
b.Property<Guid?>("LastModifierId")
.HasColumnType("uniqueidentifier")
.HasColumnName("LastModifierId");
b.Property<Guid>("SessionId")
.HasColumnType("uniqueidentifier");
b.Property<string>("UserEmail")
.HasMaxLength(200)
.HasColumnType("nvarchar(200)");
b.Property<Guid?>("UserId")
.HasColumnType("uniqueidentifier");
b.Property<string>("UserName")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("nvarchar(100)");
b.HasKey("Id");
b.HasIndex("SessionId");
b.HasIndex("UserId");
b.HasIndex("SessionId", "UserId")
.IsUnique()
.HasFilter("[UserId] IS NOT NULL");
b.ToTable("PParticipant", (string)null);
});
modelBuilder.Entity("Kurs.Platform.Entities.PaymentMethod", b => modelBuilder.Entity("Kurs.Platform.Entities.PaymentMethod", b =>
{ {
b.Property<string>("Id") b.Property<string>("Id")
@ -6213,6 +6517,17 @@ namespace Kurs.Platform.Migrations
b.Navigation("Entity"); b.Navigation("Entity");
}); });
modelBuilder.Entity("Kurs.Platform.Entities.AttendanceRecord", b =>
{
b.HasOne("Kurs.Platform.Entities.ClassSession", "Session")
.WithMany("AttendanceRecords")
.HasForeignKey("SessionId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Session");
});
modelBuilder.Entity("Kurs.Platform.Entities.BankAccount", b => modelBuilder.Entity("Kurs.Platform.Entities.BankAccount", b =>
{ {
b.HasOne("Kurs.Platform.Entities.Bank", "Bank") b.HasOne("Kurs.Platform.Entities.Bank", "Bank")
@ -6241,6 +6556,17 @@ namespace Kurs.Platform.Migrations
b.Navigation("Category"); b.Navigation("Category");
}); });
modelBuilder.Entity("Kurs.Platform.Entities.ChatMessage", b =>
{
b.HasOne("Kurs.Platform.Entities.ClassSession", "Session")
.WithMany("ChatMessages")
.HasForeignKey("SessionId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Session");
});
modelBuilder.Entity("Kurs.Platform.Entities.City", b => modelBuilder.Entity("Kurs.Platform.Entities.City", b =>
{ {
b.HasOne("Kurs.Platform.Entities.Country", "Country") b.HasOne("Kurs.Platform.Entities.Country", "Country")
@ -6304,6 +6630,17 @@ namespace Kurs.Platform.Migrations
.IsRequired(); .IsRequired();
}); });
modelBuilder.Entity("Kurs.Platform.Entities.Participant", b =>
{
b.HasOne("Kurs.Platform.Entities.ClassSession", "Session")
.WithMany("Participants")
.HasForeignKey("SessionId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Session");
});
modelBuilder.Entity("Kurs.Platform.Entities.ReportGenerated", b => modelBuilder.Entity("Kurs.Platform.Entities.ReportGenerated", b =>
{ {
b.HasOne("Kurs.Platform.Entities.ReportTemplate", "Template") b.HasOne("Kurs.Platform.Entities.ReportTemplate", "Template")
@ -6565,6 +6902,15 @@ namespace Kurs.Platform.Migrations
b.Navigation("Districts"); b.Navigation("Districts");
}); });
modelBuilder.Entity("Kurs.Platform.Entities.ClassSession", b =>
{
b.Navigation("AttendanceRecords");
b.Navigation("ChatMessages");
b.Navigation("Participants");
});
modelBuilder.Entity("Kurs.Platform.Entities.Country", b => modelBuilder.Entity("Kurs.Platform.Entities.Country", b =>
{ {
b.Navigation("Cities"); b.Navigation("Cities");

View file

@ -0,0 +1,175 @@
using System;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.SignalR;
using Volo.Abp.Domain.Repositories;
using Kurs.Platform.Entities;
using Microsoft.Extensions.Logging;
using Volo.Abp.Guids;
namespace Kurs.Platform.SignalR.Hubs;
[Authorize]
public class ClassroomHub : Hub
{
private readonly IRepository<ClassSession, Guid> _classSessionRepository;
private readonly IRepository<Participant, Guid> _participantRepository;
private readonly IRepository<ChatMessage, Guid> _chatMessageRepository;
private readonly ILogger<ClassroomHub> _logger;
private readonly IGuidGenerator _guidGenerator;
public ClassroomHub(
IRepository<ClassSession, Guid> classSessionRepository,
IRepository<Participant, Guid> participantRepository,
IRepository<ChatMessage, Guid> chatMessageRepository,
ILogger<ClassroomHub> logger,
IGuidGenerator guidGenerator)
{
_classSessionRepository = classSessionRepository;
_participantRepository = participantRepository;
_chatMessageRepository = chatMessageRepository;
_logger = logger;
_guidGenerator = guidGenerator;
}
public async Task JoinClassAsync(Guid sessionId, string userName)
{
var classSession = await _classSessionRepository.GetAsync(sessionId);
if (!classSession.CanJoin())
{
await Clients.Caller.SendAsync("Error", "Cannot join this class at this time");
return;
}
// Add to SignalR group
await Groups.AddToGroupAsync(Context.ConnectionId, sessionId.ToString());
// Update participant connection
var participant = await _participantRepository.FirstOrDefaultAsync(
x => x.SessionId == sessionId && x.UserId == Context.UserIdentifier.To<Guid>()
);
if (participant != null)
{
participant.UpdateConnectionId(Context.ConnectionId);
await _participantRepository.UpdateAsync(participant);
}
// Notify others
await Clients.Group(sessionId.ToString())
.SendAsync("ParticipantJoined", Context.UserIdentifier, userName);
_logger.LogInformation($"User {userName} joined class {sessionId}");
}
public async Task LeaveClassAsync(Guid sessionId)
{
await Groups.RemoveFromGroupAsync(Context.ConnectionId, sessionId.ToString());
await Clients.Group(sessionId.ToString())
.SendAsync("ParticipantLeft", Context.UserIdentifier);
_logger.LogInformation($"User {Context.UserIdentifier} left class {sessionId}");
}
public async Task SendSignalingMessageAsync(SignalingMessageDto message)
{
// Forward WebRTC signaling messages
await Clients.User(message.ToUserId)
.SendAsync("ReceiveSignalingMessage", message);
_logger.LogInformation($"Signaling message sent from {message.FromUserId} to {message.ToUserId}");
}
public async Task SendChatMessageAsync(Guid sessionId, string message)
{
var userId = Context.UserIdentifier.To<Guid>();
var userName = Context.User?.Identity?.Name ?? "Unknown";
// Check if user is teacher
var participant = await _participantRepository.FirstOrDefaultAsync(
x => x.SessionId == sessionId && x.UserId == userId
);
var isTeacher = participant?.IsTeacher ?? false;
// Save message to database
var chatMessage = new ChatMessage(
_guidGenerator.Create(),
sessionId,
userId,
userName,
message,
isTeacher
);
await _chatMessageRepository.InsertAsync(chatMessage);
// Send to all participants
await Clients.Group(sessionId.ToString())
.SendAsync("ChatMessage", new
{
Id = chatMessage.Id,
SenderId = chatMessage.SenderId,
SenderName = chatMessage.SenderName,
Message = chatMessage.Message,
Timestamp = chatMessage.Timestamp,
IsTeacher = chatMessage.IsTeacher
});
}
public async Task MuteParticipantAsync(Guid sessionId, Guid participantId, bool isMuted)
{
var teacherParticipant = await _participantRepository.FirstOrDefaultAsync(
x => x.SessionId == sessionId && x.UserId == Context.UserIdentifier.To<Guid>()
);
if (teacherParticipant?.IsTeacher != true)
{
await Clients.Caller.SendAsync("Error", "Only teachers can mute participants");
return;
}
var participant = await _participantRepository.FirstOrDefaultAsync(
x => x.SessionId == sessionId && x.UserId == participantId
);
if (participant != null)
{
if (isMuted)
participant.MuteAudio();
else
participant.UnmuteAudio();
await _participantRepository.UpdateAsync(participant);
// Notify the participant and others
await Clients.Group(sessionId.ToString())
.SendAsync("ParticipantMuted", participantId, isMuted);
}
}
public override async Task OnDisconnectedAsync(Exception exception)
{
// Handle cleanup when user disconnects
var userId = Context.UserIdentifier?.To<Guid>();
if (userId.HasValue)
{
var participants = await _participantRepository.GetListAsync(
x => x.UserId == userId.Value && x.ConnectionId == Context.ConnectionId
);
foreach (var participant in participants)
{
await Clients.Group(participant.SessionId.ToString())
.SendAsync("ParticipantLeft", userId.Value);
}
}
await base.OnDisconnectedAsync(exception);
}
}
public class SignalingMessageDto
{
public string Type { get; set; } // offer, answer, ice-candidate
public string FromUserId { get; set; }
public string ToUserId { get; set; }
public object Data { get; set; }
}