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 public interface IClassroomAppService : IApplicationService
{ {
Task<ClassroomDto> CreateAsync(ClassroomDto input);
Task<PagedResultDto<ClassroomDto>> GetListAsync(PagedAndSortedResultRequestDto input);
Task<ClassroomDto> GetAsync(Guid id); Task<ClassroomDto> GetAsync(Guid id);
Task<PagedResultDto<ClassroomDto>> GetListAsync(PagedAndSortedResultRequestDto input);
Task<ClassroomDto> CreateAsync(ClassroomDto input);
Task<ClassroomDto> UpdateAsync(Guid id, ClassroomDto input); Task<ClassroomDto> UpdateAsync(Guid id, ClassroomDto input);
Task DeleteAsync(Guid id); Task DeleteAsync(Guid id);
// Task<ClassroomDto> StartClassAsync(Guid id); Task<ClassroomDto> StartClassAsync(Guid id);
// Task EndClassAsync(Guid id); Task EndClassAsync(Guid id);
Task<ClassroomDto> JoinClassAsync(Guid id); Task<ClassroomDto> JoinClassAsync(Guid id);
Task LeaveClassAsync(Guid id); Task LeaveClassAsync(Guid id);
Task<List<ClassAttendanceDto>> GetAttendanceAsync(Guid sessionId); Task<List<ClassAttendanceDto>> GetAttendanceAsync(Guid sessionId);

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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