diff --git a/.gitignore b/.gitignore index cd82c93..258a123 100644 --- a/.gitignore +++ b/.gitignore @@ -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/ diff --git a/my-app/backend/app.ts b/my-app/backend/app.ts index cd5636e..90539f3 100644 --- a/my-app/backend/app.ts +++ b/my-app/backend/app.ts @@ -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()) @@ -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 @@ -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); @@ -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 @@ -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 })); diff --git a/my-app/backend/prisma.ts b/my-app/backend/prisma.ts index a8b5f0f..7b70907 100644 --- a/my-app/backend/prisma.ts +++ b/my-app/backend/prisma.ts @@ -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 }); diff --git a/my-app/backend/prisma/migrations/20260823190000_queue_zoom_link_and_timestamptz/migration.sql b/my-app/backend/prisma/migrations/20260823190000_queue_zoom_link_and_timestamptz/migration.sql new file mode 100644 index 0000000..3ca79c4 --- /dev/null +++ b/my-app/backend/prisma/migrations/20260823190000_queue_zoom_link_and_timestamptz/migration.sql @@ -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'; diff --git a/my-app/backend/prisma/schema.prisma b/my-app/backend/prisma/schema.prisma index a6aa0ae..8d4eff4 100644 --- a/my-app/backend/prisma/schema.prisma +++ b/my-app/backend/prisma/schema.prisma @@ -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) diff --git a/my-app/backend/routes/auth.routes.ts b/my-app/backend/routes/auth.routes.ts index 3e65681..1aab13a 100644 --- a/my-app/backend/routes/auth.routes.ts +++ b/my-app/backend/routes/auth.routes.ts @@ -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(); @@ -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' }); diff --git a/my-app/backend/routes/notification.routes.ts b/my-app/backend/routes/notification.routes.ts index 30db041..57b413d 100644 --- a/my-app/backend/routes/notification.routes.ts +++ b/my-app/backend/routes/notification.routes.ts @@ -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, diff --git a/my-app/backend/routes/queue.routes.ts b/my-app/backend/routes/queue.routes.ts index 788b3b0..b57402c 100644 --- a/my-app/backend/routes/queue.routes.ts +++ b/my-app/backend/routes/queue.routes.ts @@ -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'; @@ -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 => { + 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 => { @@ -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; } @@ -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 => { + 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 => { try { diff --git a/my-app/backend/routes/queueTicket.routes.ts b/my-app/backend/routes/queueTicket.routes.ts index eb25345..8c905f9 100644 --- a/my-app/backend/routes/queueTicket.routes.ts +++ b/my-app/backend/routes/queueTicket.routes.ts @@ -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 => { +// 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 => { try { const tickets = await prisma.queueTicket.findMany(); const body: QueueTicketsListResponse = { tickets, message: 'SUCCESS' }; diff --git a/my-app/backend/schemas/queue.schema.ts b/my-app/backend/schemas/queue.schema.ts index 0e92bbc..66bd0ff 100644 --- a/my-app/backend/schemas/queue.schema.ts +++ b/my-app/backend/schemas/queue.schema.ts @@ -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' }), @@ -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()), @@ -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; export type CreateQueueInput = z.infer; -export type TimeValidationInput = z.infer \ No newline at end of file +export type TimeValidationInput = z.infer; +export type ZoomLinkBodyInput = z.infer; \ No newline at end of file diff --git a/my-app/backend/services/taPresence.service.ts b/my-app/backend/services/taPresence.service.ts new file mode 100644 index 0000000..ac7899b --- /dev/null +++ b/my-app/backend/services/taPresence.service.ts @@ -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>(); +const offlineTimersByUser = new Map(); + +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 { + 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 { + cancelOfflineTimer(userId); + activeSocketsByUser.delete(userId); + return closeOpenQueuesForTa(userId); +} diff --git a/my-app/backend/services/taQueueLifecycle.service.ts b/my-app/backend/services/taQueueLifecycle.service.ts new file mode 100644 index 0000000..284ecc4 --- /dev/null +++ b/my-app/backend/services/taQueueLifecycle.service.ts @@ -0,0 +1,73 @@ +import { SessionStatus } from '../generated/prisma/client.js'; +import { prisma } from '../prisma.js'; +import { getIo } from '../socket.js'; +import { listActiveTickets } from './queue.services.js'; +import { filterRecipientsByNotificationPreference } from './notification.services.js'; +import { NotificationType as PrismaNotificationType } from '../generated/prisma/client.js'; + +const ACTIVE_STATUSES = [SessionStatus.WAITING, SessionStatus.HELPING] as const; + +/** Closes every open queue owned by a TA/PROFESSOR and clears active tickets. */ +export async function closeOpenQueuesForTa(taId: string): Promise { + const openQueues = await prisma.queue.findMany({ + where: { taId, isOpen: true }, + }); + if (openQueues.length === 0) return []; + + const queueIds = openQueues.map((queue) => queue.id); + + const ticketsByQueueId = new Map>>(); + for (const queue of openQueues) { + ticketsByQueueId.set(queue.id, await listActiveTickets(queue.id)); + } + + await prisma.queue.updateMany({ + where: { id: { in: queueIds } }, + data: { isOpen: false }, + }); + + // Delete active tickets in the queue when closing the queue + await prisma.queueTicket.deleteMany({ + where: { + queueId: { in: queueIds }, + status: { in: [...ACTIVE_STATUSES] }, + }, + }); + + const io = getIo(); + + for (const queue of openQueues) { + io.to(queue.id).emit('queue-updated', []); + + const tickets = ticketsByQueueId.get(queue.id) ?? []; + const recipientIds = [...tickets.map((ticket) => ticket.studentId), queue.taId]; + const enabledRecipientIds = await filterRecipientsByNotificationPreference( + recipientIds, + PrismaNotificationType.CLOSE, + ); + + if (enabledRecipientIds.length === 0) continue; + + // Create a closing notification + const notifications = await prisma.$transaction( + enabledRecipientIds.map((userId) => + prisma.notification.create({ + data: { queueId: queue.id, type: PrismaNotificationType.CLOSE, userId }, + include: { + ticket: { include: { student: true } }, + queue: { include: { ta: true } }, + }, + }), + ), + ); + + // Emit to the to the user on closing notification + for (const notification of notifications) { + io.to(`user:${notification.userId}`).emit('notification-created', notification); + } + } + + io.to(`user:${taId}`).emit('ta-queues-closed', { queueIds }); + + return queueIds; +} diff --git a/my-app/frontend/e2e/landing.spec.ts b/my-app/frontend/e2e/landing.spec.ts new file mode 100644 index 0000000..024196f --- /dev/null +++ b/my-app/frontend/e2e/landing.spec.ts @@ -0,0 +1,28 @@ +import { test, expect } from '@playwright/test' + +test.describe('Landing page', () => { + test('loads and shows the sign-in entry point', async ({ page }) => { + await page.goto('/') + + await expect(page.getByRole('link', { name: 'Queueble' })).toBeVisible() + await expect(page.getByRole('link', { name: 'Log In' })).toBeVisible() + }) + + test('navigates to the Google sign-in page', async ({ page }) => { + await page.goto('/') + + await page.getByRole('link', { name: 'Log In' }).first().click() + + await expect(page).toHaveURL(/\/signin$/) + await expect(page.getByRole('heading', { name: 'Sign in to Queueble' })).toBeVisible() + await expect(page.getByRole('button', { name: 'Continue with Google' })).toBeVisible() + }) +}) + +test.describe('Protected routes', () => { + test('redirects unauthenticated users away from the dashboard', async ({ page }) => { + await page.goto('/dashboard/home') + + await expect(page).toHaveURL(/\/signin$/) + }) +}) diff --git a/my-app/frontend/package-lock.json b/my-app/frontend/package-lock.json index 768afad..def8365 100644 --- a/my-app/frontend/package-lock.json +++ b/my-app/frontend/package-lock.json @@ -33,6 +33,7 @@ }, "devDependencies": { "@eslint/js": "^10.0.1", + "@playwright/test": "^1.62.1", "@types/node": "^24.13.2", "@types/react": "^19.2.17", "@types/react-dom": "^19.2.3", @@ -1963,6 +1964,22 @@ } } }, + "node_modules/@playwright/test": { + "version": "1.62.1", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.62.1.tgz", + "integrity": "sha512-DTcUc8qii+cpHvtOwggMtBRMjKZHXYWdw8syRYu2vtzuq4Wxphqq4NfCs5Zt44L6mA8rfDfj+PHnxFc/FeK6mQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.62.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/@primer/octicons": { "version": "19.33.0", "resolved": "https://registry.npmjs.org/@primer/octicons/-/octicons-19.33.0.tgz", @@ -10588,6 +10605,53 @@ "node": ">=4" } }, + "node_modules/playwright": { + "version": "1.62.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.62.1.tgz", + "integrity": "sha512-0M+L3LAD8/nm554LOla9Ayx0j0tmFZ0FBcoQ7F1VuVHpM/XpiC8RcDzBQB8W5+hA8L22THxELzeF+2WcUzvcLg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.62.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=20" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.62.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.62.1.tgz", + "integrity": "sha512-wPYSwEBJY9GHraISXqyqtx0na0LpO3XEX7jNDhntbex7tzUS7kLnZsOlFruFJB4Hi/rhDMjXGqHewDZ68nYZVw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/playwright/node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/polished": { "version": "4.3.1", "resolved": "https://registry.npmjs.org/polished/-/polished-4.3.1.tgz", diff --git a/my-app/frontend/package.json b/my-app/frontend/package.json index f7b6aee..2196855 100644 --- a/my-app/frontend/package.json +++ b/my-app/frontend/package.json @@ -8,7 +8,9 @@ "dev": "vite", "build": "tsc -b && vite build", "lint": "eslint .", - "preview": "vite preview" + "preview": "vite preview", + "e2e": "playwright test", + "e2e:ui": "playwright test --ui" }, "dependencies": { "@base-ui/react": "^1.6.0", @@ -36,6 +38,7 @@ }, "devDependencies": { "@eslint/js": "^10.0.1", + "@playwright/test": "^1.62.1", "@types/node": "^24.13.2", "@types/react": "^19.2.17", "@types/react-dom": "^19.2.3", diff --git a/my-app/frontend/playwright.config.ts b/my-app/frontend/playwright.config.ts new file mode 100644 index 0000000..c9aea26 --- /dev/null +++ b/my-app/frontend/playwright.config.ts @@ -0,0 +1,30 @@ +import { defineConfig, devices } from '@playwright/test' + +// Auth (Google OAuth via Supabase) isn't mockable without real credentials, so +// these tests cover unauthenticated flows (landing page, sign-in redirect). +// Extend with an authenticated storageState once a test Supabase user exists. +export default defineConfig({ + testDir: './e2e', + fullyParallel: true, + forbidOnly: !!process.env.CI, + retries: process.env.CI ? 2 : 0, + reporter: 'html', + use: { + baseURL: 'http://localhost:5173', + trace: 'on-first-retry', + }, + projects: [ + { + name: 'chromium', + use: { ...devices['Desktop Chrome'] }, + }, + ], + // Reuses your already-running `npm run dev` server if there is one, otherwise + // starts one for the test run. + webServer: { + command: 'npm run dev', + url: 'http://localhost:5173', + reuseExistingServer: !process.env.CI, + timeout: 30_000, + }, +}) diff --git a/my-app/frontend/src/App.tsx b/my-app/frontend/src/App.tsx index 08511c9..21c2c0b 100644 --- a/my-app/frontend/src/App.tsx +++ b/my-app/frontend/src/App.tsx @@ -40,10 +40,10 @@ function App() { +
- +
} /> } /> diff --git a/my-app/frontend/src/components/AuthCallback.tsx b/my-app/frontend/src/components/AuthCallback.tsx index 5870686..aa49f9e 100644 --- a/my-app/frontend/src/components/AuthCallback.tsx +++ b/my-app/frontend/src/components/AuthCallback.tsx @@ -85,6 +85,12 @@ export const AuthCallback = () => { try { await establishSession(session.access_token, session.refresh_token); + // The app authenticates via httpOnly cookies from here on — clear the + // Supabase client's own copy of the tokens out of localStorage so a + // client-side XSS can't read/exfiltrate them. `scope: 'local'` only + // clears this browser's storage; it does NOT revoke the refresh token, + // so the cookie session just established above keeps working. + await supabase.auth.signOut({ scope: 'local' }); await refreshUser(); navigate('/dashboard/home', { replace: true }); } catch (err: unknown) { diff --git a/my-app/frontend/src/components/DashboardClassSelector.tsx b/my-app/frontend/src/components/DashboardClassSelector.tsx index a6e89b4..af8619f 100644 --- a/my-app/frontend/src/components/DashboardClassSelector.tsx +++ b/my-app/frontend/src/components/DashboardClassSelector.tsx @@ -1,6 +1,6 @@ -import React, { useState, useEffect } from 'react' +import React, { useState, useEffect, useRef } from 'react' import axios from 'axios' -import { ChevronDown, MapPin } from 'lucide-react' +import { ChevronDown, MapPin, Video } from 'lucide-react' import { QueueModal } from './QueueModal' import type { NotificationResponse, @@ -15,6 +15,7 @@ import type { import { useAuth } from '@/context/AuthContextProvider'; import { InactiveQueueModal } from './InactiveQueueModal'; import { toast } from 'sonner'; +import { getSafeZoomLink } from '@/lib/utils'; const ACTIVE_TICKET_STATUSES = new Set(['WAITING', 'HELPING']); @@ -41,6 +42,11 @@ export const ClassSelector: React.FC = ({ Classes, selectedC // Tracks the queue currently being joined so a double-click can't fire two join requests. const [joiningQueueId, setJoiningQueueId] = useState(null); + // Track counter for active queues so user don't have to click to find the active number + const [activeQueueCounts, setActiveQueueCounts] = useState>(() => new Map()); + const [isClassDropdownOpen, setIsClassDropdownOpen] = useState(false); + const classDropdownRef = useRef(null); + // Visible queues that will be displayed (will refresh every time queue changes) const visibleQueues = queue; @@ -170,6 +176,44 @@ export const ClassSelector: React.FC = ({ Classes, selectedC } }; + useEffect(() => { + // Display the number of active queues for each course as a counter + const fetchActiveQueues = async () => { + try { + const response = await axios.get('/api/queues/active'); + if (!response.data.queues) return; + + const counts = new Map(); + for (const q of response.data.queues) { + const code = q.course?.code; + if (!code) continue; + counts.set(code, (counts.get(code) ?? 0) + 1); + } + setActiveQueueCounts(counts); + } catch (error) { + console.log('Failed to fetch active queues', error); + } + }; + void fetchActiveQueues(); + + // Poll every 15 seconds + const intervalID = setInterval(() => { void fetchActiveQueues(); }, 15000); + return () => clearInterval(intervalID); + }, []); + + useEffect(() => { + if (!isClassDropdownOpen) return; + + const handlePointerDown = (event: MouseEvent) => { + if (!classDropdownRef.current?.contains(event.target as Node)) { + setIsClassDropdownOpen(false); + } + }; + + document.addEventListener('mousedown', handlePointerDown); + return () => document.removeEventListener('mousedown', handlePointerDown); + }, [isClassDropdownOpen]); + const clearQueue = () => { setQueue([]); } @@ -250,38 +294,72 @@ export const ClassSelector: React.FC = ({ Classes, selectedC /> )} -
-
+
+
+

Select a class

-

+

Choose a course to load active office hour queues.

{/* Class search */}
-
- + {selectedClass} + + ({activeQueueCounts.get(selectedClass) ?? 0}) + + + + {isClassDropdownOpen && ( +
    + {Classes.map((courseNum) => { + const count = activeQueueCounts.get(courseNum) ?? 0; + const isSelected = courseNum === selectedClass; + + return ( +
  • + +
  • + ); + })} +
+ )}
+ {getSafeZoomLink(q.zoomLink) ? ( + + ) : null} Time: {formatQueueTime(q.startsAt)} {q.endsAt ? ` – ${formatQueueTime(q.endsAt)}` : ''} @@ -382,7 +473,7 @@ export const ClassSelector: React.FC = ({ Classes, selectedC ) })}
- )} + )} {isModalOpen && selectedQueue && ( = ({ Classes, selectedC /> )} +
); diff --git a/my-app/frontend/src/components/DashboardHome.tsx b/my-app/frontend/src/components/DashboardHome.tsx index ba93637..13b3816 100644 --- a/my-app/frontend/src/components/DashboardHome.tsx +++ b/my-app/frontend/src/components/DashboardHome.tsx @@ -40,29 +40,32 @@ export const Home = () => { } return ( -
-
+
+
+
My Tickets
{tickets.length === 0 && ( - + No active tickets to display, navigate to Class to find live office hour sessions )} - {/* 1 col < lg; 2 from lg (~content ≥720px with sidebar); 3 from xl */} -
+ {/* 1 col on small screens; 2 cols from lg so each ticket has enough room for full details */} +
{tickets.map((ticket) => ( ))}
+
) } diff --git a/my-app/frontend/src/components/DashboardQueueManager.tsx b/my-app/frontend/src/components/DashboardQueueManager.tsx index 50a5855..84cedc2 100644 --- a/my-app/frontend/src/components/DashboardQueueManager.tsx +++ b/my-app/frontend/src/components/DashboardQueueManager.tsx @@ -1,17 +1,22 @@ import { useState, useEffect, type FormEvent } from 'react' -import { MapPin, Plus, Settings2, Trash2 } from 'lucide-react' +import { MapPin, Plus, Settings2, Trash2, Video } from 'lucide-react' import type { Course, Queue, QueueTicketsListResponse, QueueTicketWithStudent } from '@shared/types' import { useAuth } from '@/context/AuthContextProvider' import { DeleteConfirmation } from './DeleteConfirmationModal' import { QueueManagementModal } from './QueueManagementModal' import axios from 'axios' - -// Date requires hour, min, s, ms -function parseTimeOnToday(time: string): Date { - const [hours, minutes] = time.split(':').map(Number) // ['14', '30'] -> [14, 30] +import { getSafeZoomLink } from '@/lib/utils' + +/** + * Bind an HH:MM wall-clock time to today in the browser's local timezone, + * then serialize as an absolute ISO instant (UTC). Supabase/Postgres stores + * this as timestamptz so display round-trips match local time. + */ +function parseTimeOnToday(time: string): string { + const [hours, minutes] = time.split(':').map(Number) const date = new Date() date.setHours(hours, minutes, 0, 0) - return date + return date.toISOString() } function formatQueueTime(value: string | Date | null | undefined): string { @@ -27,14 +32,16 @@ export interface CreateQueueInput { courseId: string taId: string location: string - startsAt: Date - endsAt: Date + zoomLink?: string | null + startsAt: string + endsAt: string } interface QueueManagerProps { createdQueues: Queue[] courses: Course[] isLoading: boolean + onCloseSidebar?: () => void // onCreateQueue is a prop for a function in the parent component onCreateQueue: (input: CreateQueueInput) => void | Promise onDeleteQueue: (queueId: string) => void | Promise @@ -45,12 +52,14 @@ export const QueueManager = ({ createdQueues, courses, isLoading, + onCloseSidebar, onCreateQueue, onDeleteQueue, onUpdateQueue }: QueueManagerProps) => { const [courseId, setCourseId] = useState('') const [location, setLocation] = useState('') + const [zoomLink, setZoomLink] = useState('') const [isCreating, setIsCreating] = useState(false) const [deletingQueueId, setDeletingQueueId] = useState(null) const [error, setError] = useState(null) @@ -65,6 +74,7 @@ export const QueueManager = ({ const [tickets, setTickets] = useState([]); const { user } = useAuth(); + const activeCourses = courses.filter((course) => course.isActive); const handleCreateQueue = async (event: FormEvent) => { event.preventDefault() @@ -77,15 +87,18 @@ export const QueueManager = ({ setError('End time cannot be before start time.') return; } + const trimmedZoom = zoomLink.trim() await onCreateQueue({ courseId, taId: user.id, location: location.trim().toUpperCase(), + zoomLink: trimmedZoom || null, startsAt: parseTimeOnToday(startTime), endsAt: parseTimeOnToday(endTime), }) setCourseId('') setLocation('') + setZoomLink('') } catch { setError('Unable to create the queue due to a limit of 1 queue per TA. Please delete the current queue and try again.') } finally { @@ -119,6 +132,7 @@ export const QueueManager = ({ const handleOpenManagementModal = async (queue: Queue) => { setCurrentQueue(queue); setIsViewingManagementModal(true); + onCloseSidebar?.(); } // When queue management modal is closed @@ -265,26 +279,26 @@ export const QueueManager = ({ )}
+ +
+ {getSafeZoomLink(queue.zoomLink) ? ( + + ) : null} Time: {formatQueueTime(queue.startsAt)} {queue.endsAt ? ` – ${formatQueueTime(queue.endsAt)}` : ''} diff --git a/my-app/frontend/src/components/DashboardSettings.tsx b/my-app/frontend/src/components/DashboardSettings.tsx index 600cc11..23fbd49 100644 --- a/my-app/frontend/src/components/DashboardSettings.tsx +++ b/my-app/frontend/src/components/DashboardSettings.tsx @@ -121,7 +121,7 @@ export const DashboardSettings = ({ prismaUser, supabaseUser, onUpdateSaveChange return ( <> -
+
{/* Settings and below layout */}

Settings

@@ -133,11 +133,11 @@ export const DashboardSettings = ({ prismaUser, supabaseUser, onUpdateSaveChange {/* General setting tabs */}
-
+
{/* Default queue location */} - {prismaUser?.role === 'TA' &&
+ {prismaUser?.role === 'TA' &&
{/* Sign out */} -
+
Sign out
-
+
Permanently delete account {/* Delete Account */} @@ -258,7 +258,7 @@ export const DashboardSettings = ({ prismaUser, supabaseUser, onUpdateSaveChange
{/* Save changes button */} -
+
setHamburgerMenuOpen(false)} className={`flex min-h-20 items-center hover:bg-gray-100 pr-6 ${LOGO_INSET}`} > @@ -171,7 +172,6 @@ export const Header = ({ featuresRef }: HeaderProps) => { setHamburgerMenuOpen(false)} className={`flex min-h-20 items-center hover:bg-gray-100 pr-6 ${LOGO_INSET}`} > @@ -179,7 +179,6 @@ export const Header = ({ featuresRef }: HeaderProps) => { setHamburgerMenuOpen(false)} className={`flex min-h-20 items-center hover:bg-gray-100 pr-6 ${LOGO_INSET}`} > diff --git a/my-app/frontend/src/components/QueueManagementModal.tsx b/my-app/frontend/src/components/QueueManagementModal.tsx index 0435c18..bdd8a64 100644 --- a/my-app/frontend/src/components/QueueManagementModal.tsx +++ b/my-app/frontend/src/components/QueueManagementModal.tsx @@ -25,7 +25,7 @@ export const QueueManagementModal = ({ onUpdateQueue, setIsViewingManagementModal, onQueueClosing, - onTimeChange + onTimeChange, }: QueueManagementModalProps) => { const toTimeInput = (value: Date | string | null | undefined) => { if (!value || value === 'null' || value === 'undefined') { @@ -51,6 +51,7 @@ export const QueueManagementModal = ({ // Save all these settings at once const [isQueueOpen, setIsQueueOpen] = useState(queue?.isOpen ?? true); const [roomLocation, setRoomLocation] = useState(queue?.location ?? ''); + const [zoomLink, setZoomLink] = useState(queue?.zoomLink ?? ''); const [startTime, setStartTime] = useState(toTimeInput(queue?.startsAt) ?? '08:00'); const [endTime, setEndTime] = useState(toTimeInput(queue?.endsAt) ?? '09:00'); @@ -90,6 +91,22 @@ export const QueueManagementModal = ({ } } + const handleChangeZoomLink = async (): Promise => { + const trimmedZoom = zoomLink.trim(); + const nextZoom = trimmedZoom || null; + if (!queue || (queue.zoomLink ?? null) === nextZoom) return queue; + try { + const response = await axios.patch(`/api/queues/${queue.id}/zoomlink`, { + zoomLink: nextZoom, + }); + if (response.status !== 200) return null; + return response.data.queue as Queue; + } catch (error) { + console.log(error); + return null; + } + } + const handleOpenQueue = () => { // Queue is already closed -> set to open if (!isQueueOpen) { @@ -136,6 +153,22 @@ export const QueueManagementModal = ({ return; } } + + const trimmedZoom = zoomLink.trim(); + const nextZoom = trimmedZoom || null; + if ((queue?.zoomLink ?? null) !== nextZoom) { + try { + updatedQueue = await handleChangeZoomLink(); + if (!updatedQueue) { + console.log('Failed to change zoom link'); + return; + } + didUpdate = true; + } catch (error) { + console.log('Unable to change zoom link', error); + return; + } + } // Change queue status only when queue's original status doesn't equal new status (API) if (queue?.isOpen !== isQueueOpen) { @@ -170,6 +203,7 @@ export const QueueManagementModal = ({ const handleCancelChanges = () => { setIsQueueOpen(queue?.isOpen ?? true); setRoomLocation(queue?.location ?? ''); + setZoomLink(queue?.zoomLink ?? ''); setStartTime(toTimeInput(queue?.startsAt)); setEndTime(toTimeInput(queue?.endsAt)); setIsViewingManagementModal(false); @@ -222,13 +256,13 @@ export const QueueManagementModal = ({ }, [queue?.id]) return ( -
-
-