Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion bsg-frontend/Dockerfile
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
FROM node:18-alpine AS builder
FROM node:20-alpine AS builder

WORKDIR /app

Expand Down
7 changes: 6 additions & 1 deletion bsg-frontend/apps/extension/hooks/useChatSocket.ts
Original file line number Diff line number Diff line change
Expand Up @@ -298,6 +298,10 @@ export const useChatSocket = () => {
chat.scrollTop = chat.scrollHeight;
};

const clearMessages = useCallback(() => {
setMessages([]);
}, []);

return {
joinChatRoom,
handleChange,
Expand All @@ -311,6 +315,7 @@ export const useChatSocket = () => {
containerRef,
counterRef,
atLimit,
MAX_CHARS
MAX_CHARS,
clearMessages,
};
};
4 changes: 2 additions & 2 deletions bsg-frontend/apps/extension/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -45,9 +45,9 @@
"eslint-config-next": "13.5.1",
"firebase": "^11.10.0",
"lucide-react": "^0.298.0",
"next": "^13.5.6",
"next": "^16.1.6",
"next-transpile-modules": "^10.0.1",
"postcss": "8.4.30",
"postcss": "^8.5.8",
"radix-ui": "^1.4.3",
"react": "18.2.0",
"react-dom": "18.2.0",
Expand Down
4 changes: 2 additions & 2 deletions bsg-frontend/apps/web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -43,9 +43,9 @@
"eslint": "8.49.0",
"eslint-config-next": "13.5.1",
"lucide-react": "^0.298.0",
"next": "^13.5.6",
"next": "^16.1.6",
"next-transpile-modules": "^10.0.1",
"postcss": "8.4.30",
"postcss": "^8.5.8",
"radix-ui": "^1.4.3",
"react": "18.2.0",
"react-dom": "18.2.0",
Expand Down
14 changes: 10 additions & 4 deletions central-service/controllers/room.go
Original file line number Diff line number Diff line change
Expand Up @@ -121,8 +121,11 @@ func (controller *RoomController) CreateNewRoundEndpoint(c echo.Context) error {
controller.logger.Error("Failed to create round", err, map[string]interface{}{
"room_id": roomID,
})
if err, ok := err.(*services.BSGError); ok {
return echo.NewHTTPError(err.StatusCode, "Failed to create round. "+err.Message)
if bsgErr, ok := err.(services.BSGError); ok {
return echo.NewHTTPError(bsgErr.StatusCode, "Failed to create round. "+bsgErr.Message)
}
if bsgErr, ok := err.(*services.BSGError); ok {
return echo.NewHTTPError(bsgErr.StatusCode, "Failed to create round. "+bsgErr.Message)
}
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to create round. Please try again later")
}
Expand All @@ -140,8 +143,11 @@ func (controller *RoomController) StartRoundEndpoint(c echo.Context) error {
"room_id": targetRoomID,
"user_id": userAuthID,
})
if err, ok := err.(*services.BSGError); ok {
return echo.NewHTTPError(err.StatusCode, "Failed to start round. "+err.Message)
if bsgErr, ok := err.(services.BSGError); ok {
return echo.NewHTTPError(bsgErr.StatusCode, "Failed to start round. "+bsgErr.Message)
}
if bsgErr, ok := err.(*services.BSGError); ok {
return echo.NewHTTPError(bsgErr.StatusCode, "Failed to start round. "+bsgErr.Message)
}
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to start round. Please try again later")
}
Expand Down
2 changes: 1 addition & 1 deletion central-service/models/round.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,6 @@ type Round struct {
Duration int `json:"duration"` // Duration in minutes
RoomID uuid.UUID `json:"roomID"`
Status string
ProblemSet []Problem `gorm:"many2many:round_problems;" json:"problems"`
ProblemSet []Problem `gorm:"many2many:round_problems;constraint:OnUpdate:CASCADE,OnDelete:CASCADE;" json:"problems"`
RoundSubmissions []RoundSubmission
}
99 changes: 75 additions & 24 deletions central-service/services/room.go
Original file line number Diff line number Diff line change
Expand Up @@ -34,10 +34,11 @@ type RoomService struct {
rtcClient *RTCClient
roomScheduler *tasks.Scheduler
MaxNumRoundsPerRoom int
ttlTaskIDs map[string]string // roomID -> scheduler task ID
}

func InitializeRoomService(db *gorm.DB, rdb *redis.Client, roundService *RoundService, rtcClient *RTCClient, roomScheduler *tasks.Scheduler, maxNumRoundsPerRoom int) RoomService {
return RoomService{db, rdb, roundService, rtcClient, roomScheduler, maxNumRoundsPerRoom}
return RoomService{db, rdb, roundService, rtcClient, roomScheduler, maxNumRoundsPerRoom, make(map[string]string)}
}

type RoomDTO struct {
Expand Down Expand Up @@ -86,11 +87,19 @@ func (service *RoomService) CreateRoom(room *RoomDTO, adminID string) (*models.R
return &newRoom, nil
}

// cancelRoomExpiry cancels the TTL expiry task for a room if one exists.
func (service *RoomService) cancelRoomExpiry(roomID string) {
if taskID, ok := service.ttlTaskIDs[roomID]; ok {
service.roomScheduler.Del(taskID)
delete(service.ttlTaskIDs, roomID)
}
}

// scheduleRoomExpiry schedules a task to delete the room after its TTL expires.
func (service *RoomService) scheduleRoomExpiry(room *models.Room) {
ttl := time.Duration(room.TTL) * time.Minute
roomID := room.ID.String()
_, err := service.roomScheduler.Add(&tasks.Task{
taskID, err := service.roomScheduler.Add(&tasks.Task{
Interval: ttl,
RunOnce: true,
TaskFunc: func() error {
Expand Down Expand Up @@ -118,24 +127,44 @@ func (service *RoomService) scheduleRoomExpiry(room *models.Room) {
})
if err != nil {
log.Printf("RoomService: failed to schedule TTL expiry for room %s: %v", roomID, err)
} else {
service.ttlTaskIDs[roomID] = taskID
}
}

// Deletes leaderboard and join time stamps from Redis
// Deletes room from Postgres
func (service *RoomService) deleteRoom(room models.Room) error {
roomID := room.ID.String()
// TODO: notify RTC room is empty
if err := service.deleteJoinMembers(roomID); err != nil {
return err
}
// Delete rounds from cascade delete
for _, round := range room.Rounds { // Delete round leaderboards
for _, round := range room.Rounds {
if err := service.roundService.DeleteLeaderboard(round.ID); err != nil {
log.Printf("Error deleting leaderboard for round %d: %v", round.ID, err)
}
// Delete round_submissions first (references round_participants and rounds)
if err := service.db.Where("round_id = ?", round.ID).Delete(&models.RoundSubmission{}).Error; err != nil {
log.Printf("Error deleting round submissions for round %d: %v", round.ID, err)
return err
}
// Delete round_participants
if err := service.db.Where("round_id = ?", round.ID).Delete(&models.RoundParticipant{}).Error; err != nil {
log.Printf("Error deleting round participants for round %d: %v", round.ID, err)
return err
}
// Delete round_problems join table entries
if err := service.db.Exec("DELETE FROM round_problems WHERE round_id = ?", round.ID).Error; err != nil {
log.Printf("Error deleting round_problems for round %d: %v", round.ID, err)
return err
}
// Delete the round itself
if err := service.db.Delete(&models.Round{}, round.ID).Error; err != nil {
log.Printf("Error deleting round %d: %v", round.ID, err)
return err
}
}
if err := service.db.Delete(room).Error; err != nil {
if err := service.db.Delete(&room).Error; err != nil {
log.Printf("Error deleting room %s: %v\n", roomID, err)
return err
}
Expand Down Expand Up @@ -239,11 +268,14 @@ func (service *RoomService) LeaveRoom(roomID string, userID string) error {
}
}
}
if users, err := service.FindActiveUsers(roomID); err != nil {
// Delete room if creator leaves or room is now empty
users, err := service.FindActiveUsers(roomID)
if err != nil {
return err
} else if len(users) <= 0 {
service.deleteRoom(*room)
return nil
}
if room.Admin == userID || len(users) == 0 {
service.cancelRoomExpiry(roomID)
return service.deleteRoom(*room)
}
if wasAdmin, err := service.IsRoomAdmin(roomID, userID); err != nil {
return err
Expand Down Expand Up @@ -334,12 +366,19 @@ func (service *RoomService) FindActiveUsers(roomID string) ([]string, error) {
}

// Removes all user join timestamps for a given room in the Redis cache
// Also clears each user's active_room pointer so stale state doesn't persist after room deletion
func (service *RoomService) deleteJoinMembers(roomID string) error {
joinKey := roomID + "_joinTimestamp"
// First collect all members so we can clear their active_room keys
members, _ := service.rdb.ZRange(context.Background(), joinKey, 0, -1).Result()
if resultCmd := service.rdb.Del(context.Background(), joinKey); resultCmd.Err() != nil {
log.Printf("Error deleting key %s: %v\n", joinKey, resultCmd.Err())
return resultCmd.Err()
}
for _, userID := range members {
activeRoomKey := fmt.Sprintf("user:%s:active_room", userID)
service.rdb.Del(context.Background(), activeRoomKey)
}
return nil
}

Expand Down Expand Up @@ -419,12 +458,14 @@ func (service *RoomService) CreateRound(params *RoundCreationParameters, roomID
}

func (service *RoomService) CheckRoundLimitExceeded(room *models.Room) (bool, error) {
var rounds []models.Round
if err := service.db.Model(room).Association("Rounds").Find(&rounds); err != nil {
var count int64
if err := service.db.Model(&models.Round{}).
Where("room_id = ? AND status != ?", room.ID, constants.ROUND_END).
Count(&count).Error; err != nil {
log.Printf("Error checking round limit: %v\n", err)
return true, err
}
return len(rounds) >= service.MaxNumRoundsPerRoom, nil
return count >= int64(service.MaxNumRoundsPerRoom), nil
}

func (service *RoomService) StartRoundByRoomID(roomID string, userID string) (*time.Time, []models.Problem, error) {
Expand All @@ -436,17 +477,24 @@ func (service *RoomService) StartRoundByRoomID(roomID string, userID string) (*t
if room.Admin != userID { // check if user is room admin
return nil, nil, BSGError{http.StatusUnauthorized, "User is not room admin. This functionality is reserved for room admin..."}
}
if len(room.Rounds) <= 0 {
log.Printf("Error initiating round start: Round has not been created")
return nil, nil, BSGError{http.StatusNotFound, "Round not found. Has not been created?"}
var round *models.Round
for i := range room.Rounds {
if room.Rounds[i].Status == constants.ROUND_CREATED {
round = &room.Rounds[i]
break
}
}
if round == nil {
log.Printf("Error initiating round start: no round in CREATED state")
return nil, nil, BSGError{http.StatusNotFound, "No round ready to start. Create a round first."}
}
round := room.Rounds[len(room.Rounds)-1]
activeUsers, err := service.FindActiveUsers(roomID)
if err != nil {
log.Printf("Error initiating round start: %v\n", err)
return nil, nil, err
}
roundStartTime, problems, err := service.roundService.InitiateRoundStart(&round, activeUsers)
roundStartTime, problems, err := service.roundService.InitiateRoundStart(round, activeUsers)

if err != nil {
log.Printf("Error initiating round start: %v\n", err)
return nil, nil, err
Expand Down Expand Up @@ -489,14 +537,17 @@ func (service *RoomService) EndRoundByRoomID(roomID string, userID string) error
if room.Admin != userID {
return BSGError{http.StatusUnauthorized, "only the room admin can end the round"}
}
if len(room.Rounds) <= 0 {
return BSGError{http.StatusNotFound, "no round found"}
var round *models.Round
for i := range room.Rounds {
if room.Rounds[i].Status == constants.ROUND_STARTED {
round = &room.Rounds[i]
break
}
}
round := room.Rounds[len(room.Rounds)-1]
if round.Status == constants.ROUND_END {
return nil // already ended, idempotent
if round == nil {
return nil // no active round — idempotent
}
if err := service.db.Model(&round).Updates(models.Round{Status: constants.ROUND_END}).Error; err != nil {
if err := service.db.Model(round).Updates(models.Round{Status: constants.ROUND_END}).Error; err != nil {
return err
}
// notify clients via RTC
Expand Down
20 changes: 20 additions & 0 deletions server/src/routes/rooms.js
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,26 @@ router.get('/:id', ensureAuth, async (req, res) => {
}
});

// Leave Room
router.post('/:id/leave', ensureAuth, async (req, res) => {
const authID = req.user.id;
const { id } = req.params;
try {
const response = await fetch(`${centralServiceUrl}/api/rooms/${id}/leave`, {
method: 'POST',
headers: {
'X-Server-Secret': serverSecret,
'X-User-Auth-ID': authID
}
});
const data = await response.json();
res.status(response.status).json(data);
} catch (error) {
console.error('Error leaving room:', error);
res.status(500).json({ error: 'Internal server error' });
}
});

// End Round
router.post('/:id/end', ensureAuth, async (req, res) => {
const authID = req.user.id;
Expand Down
Loading