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
5 changes: 5 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -42,3 +42,8 @@ my-app/shared/*.js
my-app/shared/*.js.map
my-app/shared/*.d.ts
my-app/shared/*.d.ts.map

# Playwright
my-app/frontend/test-results/
my-app/frontend/playwright-report/
my-app/frontend/playwright/.cache/
25 changes: 23 additions & 2 deletions my-app/backend/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,11 @@ import { isQueueManager } from './middlewares/authz.middleware.js';
import { startCleanupJob } from './jobs/cleanup.job.js';

// Comma-separated list of allowed browser origins (both HTTP CORS and Socket.IO CORS).
// Never fall back to a localhost default in production — a missing env var there
// must fail loudly instead of silently locking out (or worse, misconfiguring) prod CORS.
if (process.env.NODE_ENV === 'production' && !process.env.CORS_ORIGINS) {
throw new Error('CORS_ORIGINS must be set in production');
}
const ALLOWED_ORIGINS = (process.env.CORS_ORIGINS ?? 'http://localhost:5173,http://localhost:5174')
.split(',')
.map((origin) => origin.trim())
Expand All @@ -30,6 +35,11 @@ const ALLOWED_ORIGINS = (process.env.CORS_ORIGINS ?? 'http://localhost:5173,http
// Socket Services helpers
import { listActiveTickets, assertQueueViewer } from './services/queue.services.js';
import { startHelping, completeTicket } from './services/queue.services.js';
import {
isQueueManagerUser,
registerTaSocket,
unregisterTaSocket,
} from './services/taPresence.service.js';

/**
* Loads the queue a ticket belongs to and confirms the caller manages it
Expand Down Expand Up @@ -73,6 +83,11 @@ io.on('connection', async (socket: Socket) => {
// join the userId room
await socket.join(`user:${userId}`);

const tracksTaPresence = userId ? await isQueueManagerUser(userId) : false;
if (tracksTaPresence && userId) {
registerTaSocket(userId, socket.id);
}

const reportSocketError = (event: string, error: unknown) => {
const message = error instanceof Error ? error.message : 'Unexpected socket error';
console.error(`[SOCKET] ${event} failed for user ${userId}:`, message);
Expand All @@ -81,8 +96,10 @@ io.on('connection', async (socket: Socket) => {

// console.log(`User connected: ${userId}`);

socket.on('disconnect', (reason) => {
// console.log(`User ${userId} disconnected: ${reason}`);
socket.on('disconnect', () => {
if (tracksTaPresence && userId) {
unregisterTaSocket(userId, socket.id);
}
});

// Room subscriptions never create or update queue tickets, but the room does
Expand Down Expand Up @@ -148,6 +165,10 @@ io.on('connection', async (socket: Socket) => {
});
});

// Behind a reverse proxy (Render/Vercel), req.ip is otherwise the proxy's IP for every
// client, which collapses express-rate-limit's per-IP buckets into one shared bucket.
app.set('trust proxy', 1);

// Security headers + explicit HTTP CORS policy (Socket.IO has its own above).
app.use(helmet());
app.use(cors({ origin: ALLOWED_ORIGINS, credentials: true }));
Expand Down
7 changes: 6 additions & 1 deletion my-app/backend/prisma.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
import { PrismaPg } from '@prisma/adapter-pg';
import { PrismaClient } from './generated/prisma/client.js';

const adapter = new PrismaPg({ connectionString: process.env.DATABASE_URL });
// Supabase Postgres runs in UTC. Pin the session timezone so schedule
// DateTimes don't shift when this Node process runs in a local TZ (e.g. CDT).
const adapter = new PrismaPg({
connectionString: process.env.DATABASE_URL,
options: '-c timezone=UTC',
});

export const prisma = new PrismaClient({ adapter });
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
-- Optional Zoom/Meet URL per queue (separate from physical location).
ALTER TABLE "Queue" ADD COLUMN IF NOT EXISTS "zoomLink" TEXT;

-- Store schedule as absolute instants (UTC). Existing TIMESTAMP values were
-- written as UTC wall-clock by Prisma; reinterpret them as UTC.
ALTER TABLE "Queue"
ALTER COLUMN "startsAt" TYPE TIMESTAMPTZ(3)
USING "startsAt" AT TIME ZONE 'UTC';

ALTER TABLE "Queue"
ALTER COLUMN "endsAt" TYPE TIMESTAMPTZ(3)
USING "endsAt" AT TIME ZONE 'UTC';
11 changes: 8 additions & 3 deletions my-app/backend/prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -24,13 +24,18 @@ model Queue {
id String @id @default(uuid())
courseId String // foreign key points to a Course
taId String @db.Uuid // foreign key = auth.users / User.id
location String
location String
// Optional remote meeting URL (Zoom/Meet/etc). Kept separate from
// physical `location` so TAs can offer in-person, remote, or both.
zoomLink String?
isOpen Boolean @default(false)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt

startsAt DateTime
endsAt DateTime?
// Timestamptz stores an absolute instant (UTC). Avoids the TIMESTAMP-
// without-TZ shift when the Node process TZ differs from Supabase UTC.
startsAt DateTime @db.Timestamptz(3)
endsAt DateTime? @db.Timestamptz(3)

// Relations
course Course @relation(fields: [courseId], references: [id], onDelete: Cascade)
Expand Down
15 changes: 14 additions & 1 deletion my-app/backend/routes/auth.routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import type { Request, Response } from 'express';
import { supabase } from '../config/supabase.js';
import { prisma } from '../prisma.js';
import { parseGoogleDisplayName } from '../services/authUser.services.js';
import { closeQueuesOnTaLeave, isQueueManagerUser } from '../services/taPresence.service.js';

const router: Router = Router();

Expand Down Expand Up @@ -142,7 +143,19 @@ router.get('/socket-token', async (req: Request, res: Response) => {
});

router.post('/signout', async (req: Request, res: Response) => {
// console.log('Backend signout route');
const accessToken = req.cookies?.access_token;
if (accessToken) {
const { data } = await supabase.auth.getUser(accessToken);
const userId = data.user?.id;
if (userId && await isQueueManagerUser(userId)) {
try {
await closeQueuesOnTaLeave(userId);
} catch (error) {
console.error('[AUTH] Failed to close TA queues on sign-out:', error);
}
}
}

res.clearCookie('access_token', authCookieOptions);
res.clearCookie('refresh_token', authCookieOptions);
await supabase.auth.signOut({ scope: 'local' });
Expand Down
18 changes: 18 additions & 0 deletions my-app/backend/routes/notification.routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,24 @@ router.post('/queues/:queueId/user/:recipientId/type/:type', async (req: Request
return;
}

// Recipient must actually be the intended party for this notification type —
// otherwise any queue participant could redirect a notification to an
// arbitrary recipientId (spam / harassment vector).
if (type === 'JOIN' || type === 'LEAVE') {
if (recipientId !== queue.taId) {
res.status(403).json({ message: 'Recipient must be the queue TA for this notification type' });
return;
}
} else if (type === 'ASSIST') {
if (!ticket || ticket.queueId !== queueId || ticket.studentId !== recipientId) {
res.status(403).json({ message: 'Recipient must be the ticket holder in this queue for this notification type' });
return;
}
} else {
res.status(400).json({ message: 'Unsupported notification type for this endpoint' });
return;
}

const body = CreateNotificationValidationSchema.parse({
userId: recipientId,
type,
Expand Down
69 changes: 68 additions & 1 deletion my-app/backend/routes/queue.routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,11 @@ import {
QueueOpenParamSchema,
RoomLocationParamSchema,
TimeValidationSchema,
ZoomLinkBodySchema,
} from '../schemas/queue.schema.js';
import { ZodError } from 'zod';
import { closeExpiredQueues, isWithinQueueHours } from '../services/queue.services.js';
import { closeQueuesOnTaLeave } from '../services/taPresence.service.js';
import { requireQueueOwnership, requireRole } from '../middlewares/authz.middleware.js';
import type { AuthedRequest } from '../middlewares/authz.middleware.js';

Expand Down Expand Up @@ -64,6 +66,40 @@ router.get('/mine', requireRole(Role.TA, Role.PROFESSOR), async (req: AuthedRequ
}
});

// POST /api/queues/close-on-leave — immediately close all open queues for the caller (TA/PROFESSOR).
router.post('/close-on-leave', requireRole(Role.TA, Role.PROFESSOR), async (req: AuthedRequest, res: Response): Promise<void> => {
try {
const queueIds = await closeQueuesOnTaLeave(req.user!.id);
res.status(200).json({ queueIds, message: 'SUCCESS' });
} catch (error: unknown) {
if (error instanceof Error) {
res.status(500).json({ message: error.message });
return;
}
res.status(500).json({ message: 'Failed to close queues on leave' });
}
});

// GET /api/queues/isOpen
// Public api routes to fetch active courses to display the number instead of the user having to manually search for the course
router.get('/active', async (req: Request, res: Response) => {
try {
await closeExpiredQueues();
const activeQueues = await prisma.queue.findMany({
where: { isOpen: true },
include: { course: true },
});

const body: QueuesListResponse = {
queues: activeQueues,
message: 'SUCCESS',
};
res.status(200).json(body);
} catch (error) {
res.status(500).json({ message: 'Failed to fetch active queues' });
}
});

// GET /api/queues/course/:courseId — open queues for a course (student join list).
// Registered before /:id so "course" is not treated as a queue id.
router.get('/course/:courseId', async (req: Request, res: Response): Promise<void> => {
Expand Down Expand Up @@ -132,7 +168,7 @@ router.post('/', requireRole(Role.TA, Role.PROFESSOR), async (req: Request, res:
})
: null;
if (!course) {
res.status(400).json({ message: 'Select or enter a valid active course' });
res.status(400).json({ message: 'Select a valid active course from the list' });
return;
}

Expand Down Expand Up @@ -241,6 +277,37 @@ router.patch('/:queueId/location/:roomLocation', requireQueueOwnership('queueId'
}
});

// PATCH /api/queues/:queueId/zoomlink — sets or clears the optional Zoom/Meet URL. TA-owner only.
router.patch('/:queueId/zoomlink', requireQueueOwnership('queueId'), async (req: Request, res: Response): Promise<void> => {
try {
const queueId = req.params.queueId as string;
const { zoomLink } = ZoomLinkBodySchema.parse(req.body);

const result: Queue = await prisma.queue.update({
where: { id: queueId },
data: { zoomLink },
});

const body: QueueResponse = {
queue: result,
message: zoomLink
? `SUCCESSFULLY UPDATED zoom link`
: `SUCCESSFULLY CLEARED zoom link`,
};
res.status(200).json(body);
} catch (error) {
if (error instanceof ZodError) {
res.status(400).json({ message: 'Invalid input', errors: error.issues });
return;
}
if (error instanceof Error) {
res.status(500).json({ message: error.message });
return;
}
res.status(500).json({ message: 'Failed to update zoom link' });
}
});

// PATCH /api/queues/:id — toggles isOpen. TA-owner only.
router.patch('/:id', requireQueueOwnership('id'), async (req: AuthedRequest, res: Response): Promise<void> => {
try {
Expand Down
6 changes: 4 additions & 2 deletions my-app/backend/routes/queueTicket.routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,10 @@ import { ZodError } from 'zod';
import { requireQueueOwnership, requireRole, requireTicketQueueOwnership, requireSelf, requireQueueViewerAccess, requireTicketReadAccess } from '../middlewares/authz.middleware.js';
const router: Router = Router();

// GET /api/queueticket — list every ticket across all queues. TA/PROFESSOR only (admin-style view).
router.get('/', requireRole(Role.TA, Role.PROFESSOR), async (_req: Request, res: Response): Promise<void> => {
// GET /api/queueticket — list every ticket across all queues. PROFESSOR only (admin-style view).
// TAs only manage their own queue(s) — giving them a system-wide dump would leak every
// other TA's/course's tickets, so this is intentionally narrower than most TA-accessible routes.
router.get('/', requireRole(Role.PROFESSOR), async (_req: Request, res: Response): Promise<void> => {
try {
const tickets = await prisma.queueTicket.findMany();
const body: QueueTicketsListResponse = { tickets, message: 'SUCCESS' };
Expand Down
15 changes: 14 additions & 1 deletion my-app/backend/schemas/queue.schema.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
import { z } from 'zod';

/** Empty string / null → null; otherwise require an http(s) URL. */
const optionalZoomLink = z
.union([z.string().url({ message: 'Zoom link must be a valid URL' }), z.literal(''), z.null()])
.optional()
.transform((value) => (value == null || value === '' ? null : value));

// Validator
export const QueueValidationSchema = z.object({
id: z.uuid({ message: 'Invalid ID format' }),
Expand All @@ -13,6 +19,8 @@ export const QueueValidationSchema = z.object({
.min(3, { message: 'Location must be at least 3 characters' })
.trim(),

zoomLink: optionalZoomLink,

isOpen: z.boolean({ message: 'Status must be a boolean' }).optional(),

startsAt: z.coerce.date().optional().default(() => new Date()),
Expand Down Expand Up @@ -49,6 +57,11 @@ export const RoomLocationParamSchema = z
.trim()
.min(3, { message: 'Location must be at least 3 characters' });

export const ZoomLinkBodySchema = z.object({
zoomLink: optionalZoomLink,
});

export type QueueInput = z.infer<typeof QueueValidationSchema>;
export type CreateQueueInput = z.infer<typeof CreateQueueValidationSchema>;
export type TimeValidationInput = z.infer<typeof TimeValidationSchema>
export type TimeValidationInput = z.infer<typeof TimeValidationSchema>;
export type ZoomLinkBodyInput = z.infer<typeof ZoomLinkBodySchema>;
64 changes: 64 additions & 0 deletions my-app/backend/services/taPresence.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import { Role } from '../generated/prisma/client.js';
import { prisma } from '../prisma.js';
import { closeOpenQueuesForTa } from './taQueueLifecycle.service.js';

/** How long to wait after the last socket disconnects before auto-closing open queues. */
export const TA_OFFLINE_GRACE_MS = 5 * 60 * 1000;

const activeSocketsByUser = new Map<string, Set<string>>();
const offlineTimersByUser = new Map<string, NodeJS.Timeout>();

function cancelOfflineTimer(userId: string) {
const timer = offlineTimersByUser.get(userId);
if (!timer) return;
clearTimeout(timer);
offlineTimersByUser.delete(userId);
}

function scheduleOfflineClose(userId: string) {
cancelOfflineTimer(userId);
// when the timer's up, schedule deleting from map and closing the queue
const timer = setTimeout(() => {
offlineTimersByUser.delete(userId);
void closeOpenQueuesForTa(userId).catch((error) => {
console.error(`[TA PRESENCE] Failed to auto-close queues for ${userId}:`, error);
});
}, TA_OFFLINE_GRACE_MS);
offlineTimersByUser.set(userId, timer);
}

export async function isQueueManagerUser(userId: string): Promise<boolean> {
const user = await prisma.user.findUnique({
where: { id: userId },
select: { role: true },
});
return user?.role === Role.TA || user?.role === Role.PROFESSOR;
}

export function registerTaSocket(userId: string, socketId: string) {
let sockets = activeSocketsByUser.get(userId);
if (!sockets) {
sockets = new Set();
activeSocketsByUser.set(userId, sockets);
}
sockets.add(socketId);
cancelOfflineTimer(userId);
}

export function unregisterTaSocket(userId: string, socketId: string) {
const sockets = activeSocketsByUser.get(userId);
if (!sockets) return;

sockets.delete(socketId);
if (sockets.size > 0) return;

activeSocketsByUser.delete(userId);
scheduleOfflineClose(userId);
}

/** Immediate close — used on explicit sign-out or leave beacons. */
export async function closeQueuesOnTaLeave(userId: string): Promise<string[]> {
cancelOfflineTimer(userId);
activeSocketsByUser.delete(userId);
return closeOpenQueuesForTa(userId);
}
Loading
Loading