Skip to content
Open
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: 2 additions & 0 deletions src/app/(protected)/ProtectedLayoutClient.module.scss
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@
display: flex;
flex-direction: column;
min-height: 0;
height: calc(100dvh - 59px);
max-height: calc(100dvh - 59px);
overflow: hidden;
}

Expand Down
13 changes: 13 additions & 0 deletions src/app/api/contests/rooms/[id]/ready/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { auth } from "@/lib/auth";
import { getRedis } from "@/lib/redis";
import ContestRoom from "@/models/ContestRoom";
import ContestTeam from "@/models/ContestTeam";
import User from "@/models/User";
import dbConnect from "@/lib/mongodb";
import ContestMatch from "@/models/ContestMatch";
import { publishRoom } from "@/lib/contests/events";
Expand All @@ -13,6 +14,8 @@ import {
contestRoomStateSchema,
parseContestRoomProblems,
} from "@/lib/contests/runtime";
import { getDisplayName } from "@/lib/contests/names";
import { appendActivityLog } from "@/lib/contests/activityLog";
import { errorToLogMetadata, logger } from "@/lib/utils";
import { parseRouteParams } from "@/lib/api/result";
import { contestIdParamsSchema } from "@/lib/api/schemas/contestRoute";
Expand Down Expand Up @@ -76,11 +79,14 @@ export async function POST(
const readyAdded = await redis.sAdd(`room:${roomId}:ready_users`, userId);

if (readyAdded) {
const readyName = await getDisplayName(redis, userId, userTeamId);

// Publish individual ready state
await publishRoom(roomId, {
type: "room.user_ready",
roomId,
userId,
readyName,
});

// Check if this user's entire team is ready
Expand Down Expand Up @@ -179,6 +185,13 @@ export async function POST(
scores,
});

await appendActivityLog(redis, `room:${roomId}:activity_log`, {
icon: "info",
text: state.type === "arena" ? "Arena match started! Good luck." : "Match started! Good luck.",
color: "text-on-surface",
eventType: "room.state_sync"
});

// Enqueue time limit job
const timeLimitSecs = parseInt(state.timeLimit || "3600", 10);
await reconciliationQueue.add(
Expand Down
120 changes: 112 additions & 8 deletions src/app/api/contests/stream/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,10 @@ import { reconciliationQueue } from "@/lib/contests/queues";
import {
contestRoomStateSchema,
parseContestRoomProblems,
storedActivityEntrySchema,
} from "@/lib/contests/runtime";
import { getDisplayName } from "@/lib/contests/names";
import { appendActivityLog } from "@/lib/contests/activityLog";
import { parseSearchParams } from "@/lib/api/result";
import { contestStreamQuerySchema } from "@/lib/api/schemas/contestRoute";

Expand Down Expand Up @@ -41,6 +44,8 @@ export async function GET(request: NextRequest) {
: [];

const redis = await getRedis();

const initialSyncEvents: any[] = [];

for (const room of activeRooms) {
const roomId = room._id.toString();
Expand Down Expand Up @@ -94,6 +99,30 @@ export async function GET(request: NextRequest) {
cancelledForfeit: cancelled,
});

let displayName = "Unknown";
let teamIdForUser: string | null = null;
const allTeamsForName = await redis.sMembers(`room:${roomId}:teams`);
for (const tId of allTeamsForName) {
const isMember = await redis.sIsMember(`team:${tId}:users`, userId);
if (isMember) {
teamIdForUser = tId;
break;
}
}
displayName = await getDisplayName(redis, userId, teamIdForUser);


const text = cancelled
? `${displayName} reconnected. Forfeiture cancelled.`
: `${displayName} connected${currentStatus === "waiting" ? " (Not Ready)" : ""}.`;

await appendActivityLog(redis, `room:${roomId}:activity_log`, {
icon: "person",
text,
color: "text-secondary",
eventType: "presence.online"
});

// Send a full state resync directly to the reconnecting user so they catch up on any
// changes that happened while they were disconnected (missed SSE events).
if (currentStatus === "active" || currentStatus === "waiting") {
Expand All @@ -117,16 +146,32 @@ export async function GET(request: NextRequest) {
? await redis.hGetAll(`room:${roomId}:locks`)
: {};

await publishUser(userId, {
type: "room.state_sync",
roomId,
state: stateObj,
problems,
scores,
locks,
const sharedLogRaw = await redis.lRange(`room:${roomId}:activity_log`, 0, 49);
const userLogRaw = await redis.lRange(`room:${roomId}:activity_log:${userId}`, 0, 49);

const mergedLog = [...sharedLogRaw, ...userLogRaw]
.map(str => {
try { return storedActivityEntrySchema.parse(JSON.parse(str)); }
catch (e) { return null; }
})
.filter((entry): entry is NonNullable<typeof entry> => entry !== null)
.sort((a, b) => b.timestamp - a.timestamp)
.slice(0, 50);

initialSyncEvents.push({
channel: `events:user:${userId}`,
payload: {
type: "room.state_sync",
roomId,
state: stateObj,
problems,
scores,
locks,
activityLog: mergedLog,
}
});
} catch (syncErr) {
logger.error("[SSE] Failed to send reconnect state_sync:", syncErr);
logger.error("[SSE] Failed to prepare reconnect state_sync:", syncErr);
}
}
}
Expand Down Expand Up @@ -216,6 +261,24 @@ export async function GET(request: NextRequest) {
forfeitTimeout: timeoutSeconds,
});

let displayName = "Unknown";
let teamIdForUser: string | null = null;
const allTeamsForName = await redis.sMembers(`room:${roomId}:teams`);
for (const tId of allTeamsForName) {
const isMember = await redis.sIsMember(`team:${tId}:users`, userId);
if (isMember) {
teamIdForUser = tId;
break;
}
}
displayName = await getDisplayName(redis, userId, teamIdForUser);
await appendActivityLog(redis, `room:${roomId}:activity_log`, {
icon: "person_off",
text: `${displayName} disconnected. Match will be forfeited in ${timeoutSeconds}s.`,
color: "text-error",
eventType: "presence.offline"
});

await reconciliationQueue.add(
"mid_match_disconnect_timeout",
{
Expand All @@ -232,10 +295,46 @@ export async function GET(request: NextRequest) {
} else {
// Publish offline status without scheduling forfeit
await publishRoom(roomId, { type: "presence.offline", userId });

let displayName = "Unknown";
let teamIdForUser: string | null = null;
const allTeamsForName = await redis.sMembers(`room:${roomId}:teams`);
for (const tId of allTeamsForName) {
const isMember = await redis.sIsMember(`team:${tId}:users`, userId);
if (isMember) {
teamIdForUser = tId;
break;
}
}
displayName = await getDisplayName(redis, userId, teamIdForUser);
await appendActivityLog(redis, `room:${roomId}:activity_log`, {
icon: "person_off",
text: `${displayName} disconnected.`,
color: "text-error",
eventType: "presence.offline"
});
}
} else {
// If room is not active (Eg. waiting), just publish offline status normally
await publishRoom(roomId, { type: "presence.offline", userId });

let displayName = "Unknown";
let teamIdForUser: string | null = null;
const allTeamsForName = await redis.sMembers(`room:${roomId}:teams`);
for (const tId of allTeamsForName) {
const isMember = await redis.sIsMember(`team:${tId}:users`, userId);
if (isMember) {
teamIdForUser = tId;
break;
}
}
displayName = await getDisplayName(redis, userId, teamIdForUser);
await appendActivityLog(redis, `room:${roomId}:activity_log`, {
icon: "person_off",
text: `${displayName} disconnected.`,
color: "text-error",
eventType: "presence.offline"
});
}
}
} catch (err) {
Expand Down Expand Up @@ -285,6 +384,11 @@ export async function GET(request: NextRequest) {
} catch (e) {}
sendEvent("message", { channel, payload: parsed });
});

// Send the initial state syncs now that the connection is fully established
for (const ev of initialSyncEvents) {
sendEvent("message", ev);
}
} catch (err) {
logger.error("[SSE] Failed to subscribe to Redis channels:", err);
controller.error(err);
Expand Down
9 changes: 9 additions & 0 deletions src/app/api/contests/sync/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ import { auth } from "@/lib/auth";
import { getRedis } from "@/lib/redis";
import { publishUser } from "@/lib/contests/events";
import { cfSyncQueue } from "@/lib/contests/queues";
import User from "@/models/User";
import { appendActivityLog } from "@/lib/contests/activityLog";
import { logger } from "@/lib/utils";
import { parseJson } from "@/lib/api/result";
import { contestSyncSchema } from "@/lib/api/schemas/contestRoute";
Expand Down Expand Up @@ -90,6 +92,13 @@ export async function POST(request: NextRequest) {
// 5. Publish event to user
await publishUser(userId, { type: "sync.queued", position, problemId });

await appendActivityLog(redis, `room:${roomId}:activity_log:${userId}`, {
icon: "sync",
text: "Submission queued for verification...",
color: "text-secondary",
eventType: "sync.queued"
});

// 6. Return 202
return jsonOk({ queued: true }, { status: 202 });
} catch (error: unknown) {
Expand Down
Loading