From 8766ec4db67c02161cb369f41f42f6748ca49a88 Mon Sep 17 00:00:00 2001 From: ble27 Date: Sun, 23 Aug 2026 14:46:44 -0500 Subject: [PATCH 01/10] Add zoom link as option for each queue --- my-app/backend/prisma.ts | 7 +- .../migration.sql | 12 +++ my-app/backend/prisma/schema.prisma | 11 ++- my-app/backend/routes/queue.routes.ts | 34 ++++++++- my-app/backend/schemas/queue.schema.ts | 15 +++- .../src/components/DashboardClassSelector.tsx | 15 +++- .../frontend/src/components/DashboardHome.tsx | 1 + .../src/components/DashboardQueueManager.tsx | 74 ++++++++++++++----- .../src/components/QueueManagementModal.tsx | 47 ++++++++++++ my-app/frontend/src/components/QueueModal.tsx | 13 ++++ .../src/components/QueueTicketComp.tsx | 28 ++++++- .../notifications/NotificationBanner.tsx | 2 +- my-app/frontend/src/index.css | 7 ++ my-app/frontend/src/pages/Dashboard.tsx | 6 +- my-app/shared/types.ts | 1 + 15 files changed, 241 insertions(+), 32 deletions(-) create mode 100644 my-app/backend/prisma/migrations/20260823190000_queue_zoom_link_and_timestamptz/migration.sql 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/queue.routes.ts b/my-app/backend/routes/queue.routes.ts index 788b3b0..60569e3 100644 --- a/my-app/backend/routes/queue.routes.ts +++ b/my-app/backend/routes/queue.routes.ts @@ -14,6 +14,7 @@ import { QueueOpenParamSchema, RoomLocationParamSchema, TimeValidationSchema, + ZoomLinkBodySchema, } from '../schemas/queue.schema.js'; import { ZodError } from 'zod'; import { closeExpiredQueues, isWithinQueueHours } from '../services/queue.services.js'; @@ -132,7 +133,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 +242,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/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/frontend/src/components/DashboardClassSelector.tsx b/my-app/frontend/src/components/DashboardClassSelector.tsx index a6e89b4..7ae5284 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 axios from 'axios' -import { ChevronDown, MapPin } from 'lucide-react' +import { ChevronDown, MapPin, Video } from 'lucide-react' import { QueueModal } from './QueueModal' import type { NotificationResponse, @@ -345,6 +345,19 @@ export const ClassSelector: React.FC = ({ Classes, selectedC Location: {q.location || '—'} + {q.zoomLink ? ( + + ) : null} Time: {formatQueueTime(q.startsAt)} {q.endsAt ? ` – ${formatQueueTime(q.endsAt)}` : ''} diff --git a/my-app/frontend/src/components/DashboardHome.tsx b/my-app/frontend/src/components/DashboardHome.tsx index ba93637..27e825e 100644 --- a/my-app/frontend/src/components/DashboardHome.tsx +++ b/my-app/frontend/src/components/DashboardHome.tsx @@ -58,6 +58,7 @@ export const Home = () => { key={ticket.id} ticket={ticket} location={ticket.queue?.location} + zoomLink={ticket.queue?.zoomLink} taName={ticket.queue?.ta?.name ?? ticket.queue?.ta?.email} onLeave={handleLeaveTicket} /> diff --git a/my-app/frontend/src/components/DashboardQueueManager.tsx b/my-app/frontend/src/components/DashboardQueueManager.tsx index 50a5855..4585d41 100644 --- a/my-app/frontend/src/components/DashboardQueueManager.tsx +++ b/my-app/frontend/src/components/DashboardQueueManager.tsx @@ -1,17 +1,21 @@ 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] +/** + * 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,8 +31,9 @@ export interface CreateQueueInput { courseId: string taId: string location: string - startsAt: Date - endsAt: Date + zoomLink?: string | null + startsAt: string + endsAt: string } interface QueueManagerProps { @@ -51,6 +56,7 @@ export const QueueManager = ({ }: 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 +71,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 +84,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 { @@ -265,26 +275,26 @@ export const QueueManager = ({ )}
+ +