online classroom SignalR

This commit is contained in:
Sedat Öztürk 2025-08-27 23:55:01 +03:00
parent 76615b074b
commit e96faabd76
12 changed files with 102 additions and 69 deletions

View file

@ -8,13 +8,13 @@ namespace Kurs.Platform.Classrooms;
public interface IClassroomAppService : IApplicationService
{
Task<ClassroomDto> CreateAsync(ClassroomDto input);
Task<PagedResultDto<ClassroomDto>> GetListAsync(PagedAndSortedResultRequestDto input);
Task<ClassroomDto> GetAsync(Guid id);
Task<PagedResultDto<ClassroomDto>> GetListAsync(PagedAndSortedResultRequestDto input);
Task<ClassroomDto> CreateAsync(ClassroomDto input);
Task<ClassroomDto> UpdateAsync(Guid id, ClassroomDto input);
Task DeleteAsync(Guid id);
// Task<ClassroomDto> StartClassAsync(Guid id);
// Task EndClassAsync(Guid id);
Task<ClassroomDto> StartClassAsync(Guid id);
Task EndClassAsync(Guid id);
Task<ClassroomDto> JoinClassAsync(Guid id);
Task LeaveClassAsync(Guid id);
Task<List<ClassAttendanceDto>> GetAttendanceAsync(Guid sessionId);

View file

@ -121,7 +121,7 @@ public class ClassroomAppService : PlatformAppService, IClassroomAppService
}
[HttpPut]
public async Task<ClassroomDto> StartClassroomAsync(Guid id)
public async Task<ClassroomDto> StartClassAsync(Guid id)
{
var classSession = await _classSessionRepository.GetAsync(id);
@ -147,7 +147,7 @@ public class ClassroomAppService : PlatformAppService, IClassroomAppService
}
[HttpPut]
public async Task EndClassroomAsync(Guid id)
public async Task EndClassAsync(Guid id)
{
var classSession = await _classSessionRepository.GetAsync(id);

View file

@ -6,7 +6,7 @@ namespace Kurs.Platform.Entities;
public class ClassChat : FullAuditedEntity<Guid>
{
public Guid SessionId { get; set; }
public Guid SenderId { get; set; }
public Guid? SenderId { get; set; }
public string SenderName { get; set; }
public string Message { get; set; }
public DateTime Timestamp { get; set; }
@ -22,7 +22,7 @@ public class ClassChat : FullAuditedEntity<Guid>
public ClassChat(
Guid id,
Guid sessionId,
Guid senderId,
Guid? senderId,
string senderName,
string message,
bool isTeacher

View file

@ -6,6 +6,7 @@ using Volo.Abp.Domain.Repositories;
using Kurs.Platform.Entities;
using Microsoft.Extensions.Logging;
using Volo.Abp.Guids;
using Volo.Abp.Users;
namespace Kurs.Platform.SignalR.Hubs;
@ -17,21 +18,25 @@ public class ClassroomHub : Hub
private readonly IRepository<ClassChat, Guid> _chatMessageRepository;
private readonly ILogger<ClassroomHub> _logger;
private readonly IGuidGenerator _guidGenerator;
private readonly ICurrentUser _currentUser;
public ClassroomHub(
IRepository<Classroom, Guid> classSessionRepository,
IRepository<ClassParticipant, Guid> participantRepository,
IRepository<ClassChat, Guid> chatMessageRepository,
ILogger<ClassroomHub> logger,
IGuidGenerator guidGenerator)
IGuidGenerator guidGenerator,
ICurrentUser currentUser)
{
_classSessionRepository = classSessionRepository;
_participantRepository = participantRepository;
_chatMessageRepository = chatMessageRepository;
_logger = logger;
_guidGenerator = guidGenerator;
_currentUser = currentUser;
}
[HubMethodName("JoinClass")]
public async Task JoinClassAsync(Guid sessionId, string userName)
{
var classSession = await _classSessionRepository.GetAsync(sessionId);
@ -47,7 +52,7 @@ public class ClassroomHub : Hub
// Update participant connection
var participant = await _participantRepository.FirstOrDefaultAsync(
x => x.SessionId == sessionId && x.UserId == Context.UserIdentifier.To<Guid>()
x => x.SessionId == sessionId && x.UserId == _currentUser.Id
);
if (participant != null)
@ -58,16 +63,17 @@ public class ClassroomHub : Hub
// Notify others
await Clients.Group(sessionId.ToString())
.SendAsync("ParticipantJoined", Context.UserIdentifier, userName);
.SendAsync("ParticipantJoined", _currentUser.Id, userName);
_logger.LogInformation($"User {userName} joined class {sessionId}");
}
[HubMethodName("LeaveClass")]
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}");
.SendAsync("ParticipantLeft", _currentUser);
_logger.LogInformation($"User {_currentUser} left class {sessionId}");
}
public async Task SendSignalingMessageAsync(SignalingMessageDto message)
@ -80,8 +86,8 @@ public class ClassroomHub : Hub
public async Task SendChatMessageAsync(Guid sessionId, string message)
{
var userId = Context.UserIdentifier.To<Guid>();
var userName = Context.User?.Identity?.Name ?? "Unknown";
var userName = _currentUser.UserName;
var userId = _currentUser.Id;
// Check if user is teacher
var participant = await _participantRepository.FirstOrDefaultAsync(
@ -118,7 +124,7 @@ public class ClassroomHub : Hub
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>()
x => x.SessionId == sessionId && x.UserId == _currentUser.Id
);
if (teacherParticipant?.IsTeacher != true)
@ -149,7 +155,7 @@ public class ClassroomHub : Hub
public override async Task OnDisconnectedAsync(Exception exception)
{
// Handle cleanup when user disconnects
var userId = Context.UserIdentifier?.To<Guid>();
var userId = _currentUser.Id;
if (userId.HasValue)
{
var participants = await _participantRepository.GetListAsync(

View file

@ -11,6 +11,7 @@ using Kurs.Platform.BlobStoring;
using Kurs.Platform.EntityFrameworkCore;
using Kurs.Platform.Extensions;
using Kurs.Platform.Identity;
using Kurs.Platform.SignalR.Hubs;
using Kurs.Settings;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Cors;
@ -112,6 +113,8 @@ public class PlatformHttpApiHostModule : AbpModule
ConfigureHangfire(context, configuration);
ConfigureBlobStoring(configuration);
context.Services.AddSignalR();
Configure<AbpExceptionHttpStatusCodeOptions>(options =>
{
options.Map(AppErrorCodes.NoAuth, System.Net.HttpStatusCode.Unauthorized);
@ -329,7 +332,7 @@ public class PlatformHttpApiHostModule : AbpModule
fileSystem.BasePath = configuration["App:CdnPath"];
});
});
options.Containers.Configure<ImportBlobContainer>(container =>
{
container.UseFileSystem(fileSystem =>
@ -400,6 +403,11 @@ public class PlatformHttpApiHostModule : AbpModule
AsyncAuthorization = [new AbpHangfireAuthorizationFilter()]
});
}
app.UseEndpoints(endpoints =>
{
endpoints.MapHub<ClassroomHub>("/classroomhub");
});
app.UseConfiguredEndpoints();
}
}

View file

@ -10,7 +10,7 @@ export interface User {
}
export interface ClassroomDto {
id?: string
id: string
name: string
description?: string
subject?: string

View file

@ -79,7 +79,7 @@ export const ROUTES_ENUM = {
classroom: {
dashboard: '/admin/classroom/dashboard',
classes: '/admin/classroom/classes',
classroom: '/admin/classroom/room/:id',
roomDetail: '/admin/classroom/room/:id',
},
},
accessDenied: '/admin/access-denied',

View file

@ -5,8 +5,7 @@ import { PagedAndSortedResultRequestDto, PagedResultDto } from '@/proxy'
export const getClassroomById = (id: string) =>
apiService.fetchData<ClassroomDto>({
method: 'GET',
url: `/api/app/classroom`,
params: { id },
url: `/api/app/classroom/${id}`,
})
export const getClassrooms = (input: PagedAndSortedResultRequestDto) =>
@ -39,11 +38,11 @@ export const deleteClassroom = (id: string) =>
export const startClassroom = (id: string) =>
apiService.fetchData({
method: 'PUT',
url: `/api/app/${id}/start-classroom`,
url: `/api/app/classroom/${id}/start-class`,
})
export const endClassroom = (id: string) =>
apiService.fetchData({
method: 'PUT',
url: `/api/app/${id}/end-classroom`,
url: `/api/app/classroom/${id}/end-class`,
})

View file

@ -4,6 +4,7 @@ import {
HandRaiseDto,
SignalingMessageDto,
} from '@/proxy/classroom/models'
import { store } from '@/store/store'
import * as signalR from '@microsoft/signalr'
export class SignalRService {
@ -20,15 +21,17 @@ export class SignalRService {
private onHandRaiseDismissed?: (handRaiseId: string) => void
constructor() {
const { auth } = store.getState()
// Only initialize connection if not in demo mode
if (!this.demoMode) {
// In production, replace with your actual SignalR hub URL
this.connection = new signalR.HubConnectionBuilder()
.withUrl('https://localhost:5001/classroomhub', {
skipNegotiation: true,
transport: signalR.HttpTransportType.WebSockets,
.withUrl('https://localhost:44344/classroomhub', {
accessTokenFactory: () => auth.session.token || '',
})
.withAutomaticReconnect()
.configureLogging(signalR.LogLevel.Information)
.build()
this.setupEventHandlers()
@ -77,6 +80,10 @@ export class SignalRService {
this.connection.onclose(() => {
console.log('SignalR connection closed')
})
this.connection.on('Error', (message: string) => {
console.error('Hub error:', message)
})
}
async start(): Promise<void> {
@ -98,7 +105,7 @@ export class SignalRService {
}
}
async joinClass(sessionId: string, userId: string, userName: string): Promise<void> {
async joinClass(sessionId: string, userName: string): Promise<void> {
if (this.demoMode || !this.isConnected) {
console.log('Demo mode: Simulating join class for', userName)
// Simulate successful join in demo mode
@ -107,24 +114,26 @@ export class SignalRService {
}
try {
await this.connection.invoke('JoinClass', sessionId, userId, userName)
await this.connection.invoke('JoinClass', sessionId, userName)
} catch (error) {
console.error('Error joining class:', error)
}
}
async leaveClass(sessionId: string, userId: string): Promise<void> {
async leaveClass(sessionId: string): Promise<void> {
const { auth } = store.getState()
if (this.demoMode || !this.isConnected) {
console.log('Demo mode: Simulating leave class for user', userId)
console.log('Demo mode: Simulating leave class for user', auth.user.id)
// Simulate successful leave in demo mode
setTimeout(() => {
this.onParticipantLeft?.(userId)
this.onParticipantLeft?.(auth.user.id)
}, 100)
return
}
try {
await this.connection.invoke('LeaveClass', sessionId, userId)
await this.connection.invoke('LeaveClass', sessionId)
} catch (error) {
console.error('Error leaving class:', error)
}

View file

@ -76,6 +76,7 @@ platformApiService.interceptors.response.use(
email: tokenDetails?.email,
authority: [tokenDetails?.role],
name: `${tokenDetails?.given_name} ${tokenDetails?.family_name}`.trim(),
role: 'teacher',
},
})
setIsRefreshing(false)

View file

@ -98,7 +98,7 @@ const ClassList: React.FC = () => {
setClassroom(newClassEntity)
if (classroom.id) {
navigate(ROUTES_ENUM.protected.admin.classroom.classroom.replace(':id', classroom.id))
handleJoinClass(classroom)
}
} catch (error) {
console.error('Sınıf oluştururken hata oluştu:', error)
@ -152,8 +152,12 @@ const ClassList: React.FC = () => {
await startClassroom(classSession.id!)
getClassroomList()
handleJoinClass(classSession)
}
const handleJoinClass = (classSession: ClassroomDto) => {
if (classSession.id) {
navigate(ROUTES_ENUM.protected.admin.classroom.classroom.replace(':id', classSession.id))
navigate(ROUTES_ENUM.protected.admin.classroom.roomDetail.replace(':id', classSession.id))
}
}
@ -313,16 +317,21 @@ const ClassList: React.FC = () => {
<button
onClick={() => openEditModal(classSession)}
disabled={classSession.isActive}
className="flex px-3 sm:px-4 py-2 rounded-lg bg-blue-600 text-white hover:bg-blue-700"
className="flex px-3 sm:px-4 py-2 rounded-lg bg-blue-600 text-white
hover:bg-blue-700
disabled:bg-gray-400 disabled:cursor-not-allowed disabled:hover:bg-gray-400"
title="Sınıfı Düzenle"
>
<FaEdit size={14} />
Düzenle
</button>
<button
onClick={() => openDeleteModal(classSession)}
disabled={classSession.isActive}
className="flex px-3 sm:px-4 py-2 rounded-lg bg-red-600 text-white hover:bg-red-700"
className="flex px-3 sm:px-4 py-2 rounded-lg bg-red-600 text-white
hover:bg-red-700
disabled:bg-gray-400 disabled:cursor-not-allowed disabled:hover:bg-gray-400"
title="Sınıfı Sil"
>
<FaTrash size={14} />
@ -334,16 +343,10 @@ const ClassList: React.FC = () => {
<button
onClick={() =>
user.role === 'teacher' && classSession.teacherId === user.id
? handleStartClass(classSession)
: (() => {
if (classSession.id)
navigate(
ROUTES_ENUM.protected.admin.classroom.classroom.replace(
':id',
classSession.id,
),
)
})()
? classSession.isActive
? handleJoinClass(classSession)
: handleStartClass(classSession)
: handleJoinClass(classSession)
}
className={`px-3 sm:px-4 py-2 rounded-lg transition-colors ${
user.role === 'teacher' && classSession.teacherId === user.id

View file

@ -76,7 +76,7 @@ const newClassSession: ClassroomDto = {
isActive: false,
isScheduled: false,
participantCount: 0,
settings: undefined,
settingsDto: undefined,
canJoin: false,
}
@ -157,28 +157,42 @@ const RoomDetail: React.FC = () => {
},
]
useEffect(() => {
initializeServices()
return () => {
cleanup()
const fetchClassDetails = async () => {
const classEntity = await getClassroomById(params?.id ?? '')
if (classEntity) {
classEntity.data.scheduledStartTime = showDbDateAsIs(classEntity.data.scheduledStartTime)
setClassSession(classEntity.data)
}
}
useEffect(() => {
fetchClassDetails()
}, [])
useEffect(() => {
if (classSession.id) {
initializeServices()
return () => {
cleanup()
}
}
}, [classSession.id])
// Apply class settings
useEffect(() => {
if (classSession?.settings) {
setClassSettings(classSession.settings)
if (classSession?.settingsDto) {
setClassSettings(classSession.settingsDto)
const selectedLayout =
layouts.find((l) => l.id === classSession.settings!.defaultLayout) || layouts[0]
layouts.find((l) => l.id === classSession.settingsDto!.defaultLayout) || layouts[0]
setCurrentLayout(selectedLayout)
// Apply default audio/video states for new participants
if (user.role === 'student') {
setIsAudioEnabled(classSession.settings.defaultMicrophoneState === 'unmuted')
setIsVideoEnabled(classSession.settings.defaultCameraState === 'on')
setIsAudioEnabled(classSession.settingsDto.defaultMicrophoneState === 'unmuted')
setIsVideoEnabled(classSession.settingsDto.defaultCameraState === 'on')
}
}
}, [classSession?.settings, user.role])
}, [classSession?.settingsDto, user.role])
useEffect(() => {
scrollToBottom()
@ -190,13 +204,6 @@ const RoomDetail: React.FC = () => {
const initializeServices = async () => {
try {
//ClassEntity
const classEntity = await getClassroomById(params?.id ?? '')
if (classEntity) {
classEntity.data.scheduledStartTime = showDbDateAsIs(classEntity.data.scheduledStartTime)
setClassSession(classEntity.data)
}
// Initialize SignalR
signalRServiceRef.current = new SignalRService()
await signalRServiceRef.current.start()
@ -275,7 +282,7 @@ const RoomDetail: React.FC = () => {
})
// Join the class
await signalRServiceRef.current.joinClass(classSession.id, user.id, user.name)
await signalRServiceRef.current.joinClass(classSession.id, user.name)
} catch (error) {
console.error('Failed to initialize services:', error)
}
@ -283,7 +290,7 @@ const RoomDetail: React.FC = () => {
const cleanup = async () => {
if (signalRServiceRef.current) {
await signalRServiceRef.current.leaveClass(classSession.id, user.id)
await signalRServiceRef.current.leaveClass(classSession.id)
await signalRServiceRef.current.disconnect()
}
@ -518,9 +525,9 @@ const RoomDetail: React.FC = () => {
})
// Add attendance record
setAttendanceRecords((prev) => {
setAttendanceRecords((prev: any) => {
// Check if student already has an active attendance record
const existingRecord = prev.find((r) => r.studentId === studentId && !r.leaveTime)
const existingRecord = prev.find((r: any) => r.studentId === studentId && !r.leaveTime)
if (existingRecord) return prev
return [