diff --git a/.gitignore b/.gitignore index 64ca95b..0cc59ad 100644 --- a/.gitignore +++ b/.gitignore @@ -75,7 +75,6 @@ temp.txt # Internal AI docs (kept locally, not tracked) docs/agentgpt.md docs/context.md -docs/backend.md docs/frontend.md docs/project-understanding.md docs/architecture_understanding.md diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 85d772f..a6a1684 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -61,7 +61,14 @@ ClassHub is a multi-tenant academic progressive web application built for colleg ``` Fill in your Supabase project URL, anonymous public key, and VAPID key in `.env`. -5. Start the Vite development server: +5. (Optional) Run local Supabase database stack: + ```bash + supabase start # Starts local Postgres, Auth, Storage, and Studio + supabase db reset # Applies all migrations and seeds deterministic Section P2 data from supabase/seed.sql + ``` + For detailed database architecture and seeding documentation, refer to [docs/backend.md](docs/backend.md). + +6. Start the Vite development server: ```bash npm run dev ``` diff --git a/docs/backend.md b/docs/backend.md new file mode 100644 index 0000000..7b193ce --- /dev/null +++ b/docs/backend.md @@ -0,0 +1,92 @@ +# ClassHub Backend & Database Architecture + +This document describes the backend architecture, local Supabase CLI development workflow, schema relationships, Row-Level Security (RLS) enforcement, and local seed scripts for ClassHub. + +--- + +## 1. Local Supabase CLI Development Workflow + +ClassHub uses the Supabase CLI for local database development, migrations, and Edge Function testing. + +### Prerequisites + +- [Docker Desktop](https://www.docker.com/) running locally. +- [Supabase CLI](https://supabase.com/docs/guides/cli) installed (`npm install -g supabase` or `brew install supabase/tap/supabase`). + +### Starting the Local Stack + +1. Initialize and start the local Supabase emulator: + ```bash + supabase start + ``` + This spins up PostgreSQL 15, Auth, Storage, and Studio at `http://localhost:54323`. + +2. Apply all active migrations and run the deterministic seed script: + ```bash + supabase db reset + ``` + This resets the local database, executes all SQL files under `supabase/migrations/` in chronological order, and executes `supabase/seed.sql`. + +3. Access Supabase Studio locally: + - URL: `http://localhost:54323` + - Default local API URL: `http://localhost:54321` + - Default local anon key is output by `supabase status`. + +--- + +## 2. Deterministic Local Seed Data (`supabase/seed.sql`) + +When setting up locally or running automated backend tests, `supabase/seed.sql` populates a realistic, reproducible environment for **Section P2**: + +- **Demo Section**: Section `P2` (`invite_code`: `P2WXYZ`). +- **Demo Users**: + - **CR**: `cr.p2@skit.ac.in` (Roll: `01`, University Roll: `22ESKCS001`) + - **Student**: `student.p2@skit.ac.in` (Roll: `02`, University Roll: `22ESKCS002`) + - **Faculty**: `teacher.p2@skit.ac.in` (`Dr. Sunita Gupta`) +- **Core Engineering Subjects**: DBMS (`CS401`), Operating Systems (`CS402`), Computer Networks (`CS403`), Data Structures (`CS404`). +- **Weekly Schedule**: Recurring Monday through Wednesday timetable slots with assigned rooms (`LT-101`, `Lab-3`). +- **Sample Attendance Records**: Pre-populated attendance logs with safe bunks and threshold metrics. +- **Notices & Q&A**: High-priority announcements with verified student Q&A responses. + +> [!IMPORTANT] +> The seed script strictly uses deterministic static UUIDs (such as `00000000-0000-4000-8000-000000000001`) and zero real student personal identifiable information (PII). + +--- + +## 3. Database Schema & Tenant Isolation + +ClassHub operates on a strict multi-tenant model isolated at the **Section** level. + +``` +sections (id, name, college, invite_code) + ├── users (id, name, email, role, section_id, section_roll, university_roll) + ├── subjects (id, section_id, code, name, semester) + ├── timetable_slots (id, section_id, subject_id, day_of_week, start_time, end_time, room) + ├── attendance_records (id, user_id, subject_id, present, od, makeup, absent) + ├── announcements (id, section_id, author_id, title, message_content, priority) + │ ├── acknowledgments (announcement_id, user_id) + │ ├── announcement_comments (id, announcement_id, author_id, content, is_verified) + │ └── announcement_reactions (id, announcement_id, user_id, emoji) + └── assignments (id, section_id, subject_id, title, due_date) + ├── assignment_sets (id, assignment_id, set_label, roll_start, roll_end) + └── submissions (id, assignment_id, student_id, submission_link, status) +``` + +### Security & Row-Level Security (RLS) Rules + +1. **Section Isolation**: Every query must be scoped to the authenticated user's `section_id` via Postgres function `public.current_user_section_id()`. +2. **Domain Protection**: Only accounts with the `@skit.ac.in` Google Workspace domain are permitted to authenticate. +3. **Zero ERP Passwords**: ClassHub never scrapes, requests, or stores external ERP passwords. +4. **Anonymous Poll Tokenization**: Anonymous voting utilizes salted one-way hashes (`calculate_anonymous_token`) to preserve ballot secrecy. + +--- + +## 4. Creating New Database Migrations + +To introduce schema modifications or new database functions: + +```bash +supabase migration new +``` + +Write idempotent SQL in the generated file under `supabase/migrations/`. Ensure all new tables have `ALTER TABLE ... ENABLE ROW LEVEL SECURITY;` and corresponding RLS policies. diff --git a/src/components/CopyButton.tsx b/src/components/CopyButton.tsx new file mode 100644 index 0000000..1d979ae --- /dev/null +++ b/src/components/CopyButton.tsx @@ -0,0 +1,74 @@ +import React, { useState } from 'react'; +import { Copy, Check } from 'lucide-react'; +import { copyToClipboard } from '../lib/utils/clipboard'; + +export interface CopyButtonProps { + text: string; + label?: string; + ariaLabel?: string; + successMessage?: string; + iconSize?: number; + className?: string; + style?: React.CSSProperties; + children?: React.ReactNode; +} + +export function CopyButton({ + text, + label, + ariaLabel = 'Copy to clipboard', + successMessage = 'Copied to clipboard!', + iconSize = 13, + className, + style, + children, +}: CopyButtonProps) { + const [copied, setCopied] = useState(false); + + const handleCopy = async (e: React.MouseEvent) => { + e.stopPropagation(); + const success = await copyToClipboard(text, { + successMessage, + errorMessage: 'Clipboard permission denied', + }); + + if (success) { + setCopied(true); + setTimeout(() => setCopied(false), 2000); + } + }; + + return ( + + ); +} diff --git a/src/components/announcement-qa/AnnouncementCommentsDrawer.tsx b/src/components/announcement-qa/AnnouncementCommentsDrawer.tsx index f1388a6..d88be06 100644 --- a/src/components/announcement-qa/AnnouncementCommentsDrawer.tsx +++ b/src/components/announcement-qa/AnnouncementCommentsDrawer.tsx @@ -19,7 +19,7 @@ import { import { useSectionMembers } from '../../hooks/useSectionMembers'; import { useUserTagsBatch } from '../../hooks/useUserTags'; import { TagPill, TagOverflow } from '../TagPill'; -import { MAX_COMMENT_LENGTH } from '../../lib/validation/comments.schema'; +import { MAX_COMMENT_LENGTH, commentSchema } from '../../lib/validation/comments.schema'; interface AnnouncementCommentsDrawerProps { open: boolean; @@ -169,7 +169,13 @@ export function AnnouncementCommentsDrawer({ const handlePostComment = async (e: React.FormEvent) => { e.preventDefault(); - if (!inputVal.trim() || inputVal.length > MAX_COMMENT_LENGTH || isSubmitting) return; + if (isSubmitting) return; + + const validation = commentSchema.safeParse({ content: inputVal }); + if (!validation.success) { + toast.error(validation.error.issues[0]?.message || 'Invalid comment'); + return; + } // Rate-limiting: Max 1 comment per 3 seconds const now = Date.now(); @@ -180,7 +186,7 @@ export function AnnouncementCommentsDrawer({ setIsSubmitting(true); try { - await addComment.mutateAsync(inputVal); + await addComment.mutateAsync(validation.data.content); setInputVal(''); setLastSubmitTime(now); toast.success('Comment posted ✓'); @@ -216,10 +222,16 @@ export function AnnouncementCommentsDrawer({ }; const handleSaveEdit = async (commentId: string) => { - if (!editInputVal.trim() || editInputVal.length > MAX_COMMENT_LENGTH || editComment.isPending) return; + if (editComment.isPending) return; + + const validation = commentSchema.safeParse({ content: editInputVal }); + if (!validation.success) { + toast.error(validation.error.issues[0]?.message || 'Invalid comment'); + return; + } try { - await editComment.mutateAsync({ commentId, content: editInputVal }); + await editComment.mutateAsync({ commentId, content: validation.data.content }); setEditingCommentId(null); } catch { // Error handled in hook diff --git a/src/lib/utils/clipboard.ts b/src/lib/utils/clipboard.ts new file mode 100644 index 0000000..a754cd4 --- /dev/null +++ b/src/lib/utils/clipboard.ts @@ -0,0 +1,80 @@ +import { toast } from 'sonner'; + +export interface CopyOptions { + successMessage?: string; + errorMessage?: string; + showToast?: boolean; +} + +/** + * Resilient copy-to-clipboard utility. + * Attempts modern navigator.clipboard.writeText with a fallback to document.execCommand('copy') + * for unsupported, legacy, or insecure contexts. + */ +export async function copyToClipboard( + text: string, + options: CopyOptions = {} +): Promise { + const { + successMessage, + errorMessage = 'Clipboard permission denied', + showToast = true, + } = options; + + if (!text) { + if (showToast) { + toast.error(errorMessage); + } + return false; + } + + // 1. Try modern navigator.clipboard API + if (typeof navigator !== 'undefined' && navigator.clipboard && typeof navigator.clipboard.writeText === 'function') { + try { + await navigator.clipboard.writeText(text); + if (showToast && successMessage) { + toast.success(successMessage); + } + return true; + } catch { + // Modern clipboard failed or was rejected by permissions policy; fall through to legacy fallback + } + } + + // 2. Legacy fallback: document.execCommand('copy') + if (typeof document !== 'undefined') { + try { + const textarea = document.createElement('textarea'); + textarea.value = text; + // Ensure textarea is not visible or disruptive to user focus + textarea.setAttribute('readonly', ''); + textarea.style.position = 'fixed'; + textarea.style.left = '-9999px'; + textarea.style.top = '0'; + textarea.style.opacity = '0'; + textarea.style.pointerEvents = 'none'; + + document.body.appendChild(textarea); + textarea.focus(); + textarea.select(); + + const successful = document.execCommand('copy'); + document.body.removeChild(textarea); + + if (successful) { + if (showToast && successMessage) { + toast.success(successMessage); + } + return true; + } + } catch { + // Fallback failed + } + } + + // 3. Complete failure handling + if (showToast) { + toast.error(errorMessage); + } + return false; +} diff --git a/src/lib/validation/comments.schema.ts b/src/lib/validation/comments.schema.ts index 2c657d4..18bb714 100644 --- a/src/lib/validation/comments.schema.ts +++ b/src/lib/validation/comments.schema.ts @@ -12,3 +12,9 @@ export const commentContentSchema = z .max(MAX_COMMENT_LENGTH, `Comment must be ${MAX_COMMENT_LENGTH} characters or fewer`); export type CommentContent = z.infer; + +export const commentSchema = z.object({ + content: commentContentSchema, +}); + +export type CommentInput = z.infer; diff --git a/src/pages/app/PDFViewerPage.tsx b/src/pages/app/PDFViewerPage.tsx index 2121c6b..fa8ad77 100644 --- a/src/pages/app/PDFViewerPage.tsx +++ b/src/pages/app/PDFViewerPage.tsx @@ -858,17 +858,44 @@ export default function PDFViewerPage() { jumpToPage(parseInt(pageInputValue, 10)); }; - const goToPrevPage = () => { + const goToPrevPage = useCallback(() => { if (activePageNum > 1) { jumpToPage(activePageNum - 1); } - }; + }, [activePageNum, jumpToPage]); - const goToNextPage = () => { + const goToNextPage = useCallback(() => { if (activePageNum < numPages) { jumpToPage(activePageNum + 1); } - }; + }, [activePageNum, numPages, jumpToPage]); + + // Keyboard navigation shortcuts for desktop/laptop navigation + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + const target = e.target as HTMLElement | null; + const tagName = target?.tagName?.toUpperCase(); + if (tagName === 'INPUT' || tagName === 'TEXTAREA' || tagName === 'SELECT' || target?.isContentEditable) { + return; + } + + if (e.key === 'ArrowRight' || e.key === 'ArrowDown') { + e.preventDefault(); + goToNextPage(); + } else if (e.key === 'ArrowLeft' || e.key === 'ArrowUp') { + e.preventDefault(); + goToPrevPage(); + } else if (e.key === 'Escape') { + e.preventDefault(); + navigate(-1); + } + }; + + window.addEventListener('keydown', handleKeyDown); + return () => { + window.removeEventListener('keydown', handleKeyDown); + }; + }, [goToNextPage, goToPrevPage, navigate]); const handleZoomIn = () => { setScale(prev => Math.min(prev + 0.2, 3.0)); diff --git a/src/pages/app/ProfilePage.tsx b/src/pages/app/ProfilePage.tsx index 9ce922b..ec37218 100644 --- a/src/pages/app/ProfilePage.tsx +++ b/src/pages/app/ProfilePage.tsx @@ -1,6 +1,6 @@ import { useState, useEffect } from 'react'; import { useNavigate } from 'react-router-dom'; -import { Copy, Check, ChevronRight, ChevronDown, Bell, Trash2, Download, Calculator, AlertTriangle, LogOut, ExternalLink, MessageSquare, Calendar, Plus, Users, Mail, Loader2, Heart, Star, BookOpen, Pencil } from 'lucide-react'; +import { ChevronRight, ChevronDown, Bell, Trash2, Download, Calculator, AlertTriangle, LogOut, ExternalLink, MessageSquare, Calendar, Plus, Users, Mail, Loader2, Heart, Star, BookOpen, Pencil } from 'lucide-react'; import { motion, AnimatePresence } from 'motion/react'; import { useQuery } from '@tanstack/react-query'; import { NavBar } from '../../components/NavBar'; @@ -15,6 +15,7 @@ import { useUserTags, useDeleteTag, MAX_ACTIVE_TAGS } from '../../hooks/useUserT import { TagPill } from '../../components/TagPill'; import { AddTagSheet } from '../../components/AddTagSheet'; import { BottomSheet } from '../../components/BottomSheet'; +import { CopyButton } from '../../components/CopyButton'; import { logEvent } from '../../lib/analytics'; export default function ProfilePage() { @@ -139,15 +140,6 @@ export default function ProfilePage() { const initials = displayName.split(' ').map((n: string) => n[0]).join('').slice(0, 2).toUpperCase(); - const [copied, setCopied] = useState(false); - - const handleCopy = () => { - navigator.clipboard.writeText(hubCode); - setCopied(true); - toast.success('Hub code copied!'); - setTimeout(() => setCopied(false), 2000); - }; - const handleDeleteAccount = async () => { if (deleteInput !== 'DELETE') return; setDeleting(true); @@ -406,8 +398,16 @@ export default function ProfilePage() { {authUser.branch} )} {displayRole !== 'teacher' && ( - - Roll {classRoll} + + Roll {classRoll} + {classRoll && classRoll !== '—' && ( + + )} )} @@ -497,30 +497,27 @@ export default function ProfilePage() { {/* Hub Code with Copy */} - + /> {/* Side-by-side action tiles (Directory & Curriculum) */} @@ -743,9 +740,19 @@ export default function ProfilePage() { padding: '14px 16px', borderBottom: counsellor ? '1px solid var(--border-default)' : 'none', }}> University Roll - - {universityRoll || '—'} - +
+ + {universityRoll || '—'} + + {universityRoll && universityRoll !== '—' && ( + + )} +
)} diff --git a/src/pages/app/SectionDirectoryPage.tsx b/src/pages/app/SectionDirectoryPage.tsx index 59c58c0..24b040f 100644 --- a/src/pages/app/SectionDirectoryPage.tsx +++ b/src/pages/app/SectionDirectoryPage.tsx @@ -1,6 +1,6 @@ import { useMemo, useRef, useState, useEffect } from 'react'; import { useNavigate, useSearchParams } from 'react-router-dom'; -import { ArrowLeft, X, Users, Mail, Copy, Search, Pencil, UserX, AlertTriangle, Loader2, SlidersHorizontal, ChevronDown, Check } from 'lucide-react'; +import { ArrowLeft, X, Users, Mail, Search, Pencil, UserX, AlertTriangle, Loader2, SlidersHorizontal, ChevronDown, Check } from 'lucide-react'; import * as DropdownMenu from '@radix-ui/react-dropdown-menu'; import { useWindowVirtualizer } from '@tanstack/react-virtual'; import Skeleton from 'react-loading-skeleton'; @@ -13,6 +13,7 @@ import { useUserTagsBatch, useDeleteTag } from '../../hooks/useUserTags'; import { useAppStore } from '../../store/appStore'; import { TagPill, TagOverflow } from '../../components/TagPill'; import { BottomSheet } from '../../components/BottomSheet'; +import { CopyButton } from '../../components/CopyButton'; import { toast } from 'sonner'; const MAX_VISIBLE_TAGS = 3; @@ -581,6 +582,19 @@ export default function SectionDirectoryPage() { {teacher.email}
+ {teacher.phone && ( +
+ + +91 {teacher.phone} + + +
+ )} @@ -1030,8 +1044,14 @@ export default function SectionDirectoryPage() { {/* Phone & Roll */}
0 ? 4 : 0 }}> {member.universityRoll && ( - - {member.universityRoll} + + {member.universityRoll} + )} {member.phone && ( @@ -1039,24 +1059,12 @@ export default function SectionDirectoryPage() { +91 {member.phone} - +
)} diff --git a/supabase/seed.sql b/supabase/seed.sql index 4a91e62..87c5059 100644 --- a/supabase/seed.sql +++ b/supabase/seed.sql @@ -1,11 +1,193 @@ -insert into public.sections (id, college, name, invite_code) -values ('00000000-0000-4000-8000-000000000001', 'SKIT Jaipur', 'P2', 'P2WXYZ') -on conflict (invite_code) do nothing; - -insert into public.subjects (section_id, code, name, semester, accent) -values - ('00000000-0000-4000-8000-000000000001', 'CSUL201', 'Problem Solving Using OOP', 2, '#4A9EFF'), - ('00000000-0000-4000-8000-000000000001', 'DBMS201', 'Database Management Systems', 2, '#34C97B'), - ('00000000-0000-4000-8000-000000000001', 'OSL201', 'Operating Systems Lab', 2, '#FFB547'), - ('00000000-0000-4000-8000-000000000001', 'CHEM101', 'Engineering Chemistry', 2, '#FF4444') -on conflict (section_id, code) do nothing; +-- ============================================================================ +-- ClassHub Deterministic Local Development Seed Script +-- Safe for local development via Supabase CLI (`supabase db reset`) +-- Contains zero PII and zero real production credentials. +-- ============================================================================ + +-- 1. Demo Section (Section P2) +INSERT INTO public.sections (id, college, name, invite_code) +VALUES ( + '00000000-0000-4000-8000-000000000001', + 'SKIT Jaipur', + 'P2', + 'P2WXYZ' +) +ON CONFLICT (id) DO NOTHING; + +-- 2. Mock Auth Users +INSERT INTO auth.users ( + id, + instance_id, + aud, + role, + email, + encrypted_password, + email_confirmed_at, + raw_app_meta_data, + raw_user_meta_data, + created_at, + updated_at +) +VALUES + ( + '00000000-0000-4000-8000-000000000010', + '00000000-0000-0000-0000-000000000000', + 'authenticated', + 'authenticated', + 'cr.p2@skit.ac.in', + crypt('ClassHubDemo2026!', gen_salt('bf')), + now(), + '{"provider":"google","providers":["google"]}', + '{"name":"Aarav Sharma (CR)"}', + now(), + now() + ), + ( + '00000000-0000-4000-8000-000000000011', + '00000000-0000-0000-0000-000000000000', + 'authenticated', + 'authenticated', + 'student.p2@skit.ac.in', + crypt('ClassHubDemo2026!', gen_salt('bf')), + now(), + '{"provider":"google","providers":["google"]}', + '{"name":"Rohan Verma"}', + now(), + now() + ), + ( + '00000000-0000-4000-8000-000000000012', + '00000000-0000-0000-0000-000000000000', + 'authenticated', + 'authenticated', + 'teacher.p2@skit.ac.in', + crypt('ClassHubDemo2026!', gen_salt('bf')), + now(), + '{"provider":"google","providers":["google"]}', + '{"name":"Dr. Sunita Gupta"}', + now(), + now() + ) +ON CONFLICT (id) DO NOTHING; + +-- 3. Public User Profiles +INSERT INTO public.users ( + id, + name, + email, + role, + section_id, + section_roll, + university_roll, + day_scholar, + notifications_enabled +) +VALUES + ( + '00000000-0000-4000-8000-000000000010', + 'Aarav Sharma', + 'cr.p2@skit.ac.in', + 'cr', + '00000000-0000-4000-8000-000000000001', + '01', + '22ESKCS001', + true, + true + ), + ( + '00000000-0000-4000-8000-000000000011', + 'Rohan Verma', + 'student.p2@skit.ac.in', + 'student', + '00000000-0000-4000-8000-000000000001', + '02', + '22ESKCS002', + true, + false + ), + ( + '00000000-0000-4000-8000-000000000012', + 'Dr. Sunita Gupta', + 'teacher.p2@skit.ac.in', + 'teacher', + '00000000-0000-4000-8000-000000000001', + null, + null, + true, + true + ) +ON CONFLICT (id) DO UPDATE SET + name = EXCLUDED.name, + role = EXCLUDED.role, + section_id = EXCLUDED.section_id, + section_roll = EXCLUDED.section_roll, + university_roll = EXCLUDED.university_roll; + +-- 4. Core Engineering Subjects +INSERT INTO public.subjects (id, section_id, code, name, semester, accent) +VALUES + ('00000000-0000-4000-8000-000000000021', '00000000-0000-4000-8000-000000000001', 'CS401', 'Database Management Systems', 4, '#4A9EFF'), + ('00000000-0000-4000-8000-000000000022', '00000000-0000-4000-8000-000000000001', 'CS402', 'Operating Systems', 4, '#34C97B'), + ('00000000-0000-4000-8000-000000000023', '00000000-0000-4000-8000-000000000001', 'CS403', 'Computer Networks', 4, '#FFB547'), + ('00000000-0000-4000-8000-000000000024', '00000000-0000-4000-8000-000000000001', 'CS404', 'Data Structures & Algorithms', 4, '#A78BFA') +ON CONFLICT (id) DO NOTHING; + +-- 5. Timetable Slots (Recurring Schedule) +INSERT INTO public.timetable_slots (id, section_id, subject_id, day_of_week, start_time, end_time, room, type, created_by) +VALUES + ('00000000-0000-4000-8000-000000000031', '00000000-0000-4000-8000-000000000001', '00000000-0000-4000-8000-000000000021', 1, '09:00:00', '10:00:00', 'LT-101', 'lecture', '00000000-0000-4000-8000-000000000010'), + ('00000000-0000-4000-8000-000000000032', '00000000-0000-4000-8000-000000000001', '00000000-0000-4000-8000-000000000022', 1, '10:00:00', '11:00:00', 'LT-101', 'lecture', '00000000-0000-4000-8000-000000000010'), + ('00000000-0000-4000-8000-000000000033', '00000000-0000-4000-8000-000000000001', '00000000-0000-4000-8000-000000000023', 2, '09:00:00', '10:00:00', 'LT-102', 'lecture', '00000000-0000-4000-8000-000000000010'), + ('00000000-0000-4000-8000-000000000034', '00000000-0000-4000-8000-000000000001', '00000000-0000-4000-8000-000000000024', 3, '11:00:00', '13:00:00', 'Lab-3', 'lab', '00000000-0000-4000-8000-000000000010') +ON CONFLICT (id) DO NOTHING; + +-- 6. Sample Attendance Records +INSERT INTO public.attendance_records (id, user_id, subject_id, present, od, makeup, absent) +VALUES + ('00000000-0000-4000-8000-000000000041', '00000000-0000-4000-8000-000000000011', '00000000-0000-4000-8000-000000000021', 24, 2, 0, 4), + ('00000000-0000-4000-8000-000000000042', '00000000-0000-4000-8000-000000000011', '00000000-0000-4000-8000-000000000022', 20, 0, 1, 5), + ('00000000-0000-4000-8000-000000000043', '00000000-0000-4000-8000-000000000011', '00000000-0000-4000-8000-000000000023', 18, 1, 0, 3), + ('00000000-0000-4000-8000-000000000044', '00000000-0000-4000-8000-000000000011', '00000000-0000-4000-8000-000000000024', 22, 0, 0, 2) +ON CONFLICT (id) DO NOTHING; + +-- 7. Sample Announcements +INSERT INTO public.announcements (id, section_id, author_id, title, message_content, priority, is_pinned) +VALUES + ( + '00000000-0000-4000-8000-000000000051', + '00000000-0000-4000-8000-000000000001', + '00000000-0000-4000-8000-000000000010', + 'Mid-Term Exam Schedule Released', + 'Please review the updated mid-term examination timetable on the notice board and verify room numbers for Section P2.', + 'critical', + true + ), + ( + '00000000-0000-4000-8000-000000000052', + '00000000-0000-4000-8000-000000000001', + '00000000-0000-4000-8000-000000000010', + 'DBMS Lab Submission Deadline', + 'All students must submit their Lab Assignment 3 question sets by Friday 5:00 PM.', + 'general', + false + ) +ON CONFLICT (id) DO NOTHING; + +-- 8. Sample Announcement Q&A Comments +INSERT INTO public.announcement_comments (id, announcement_id, author_id, content, is_verified) +VALUES + ( + '00000000-0000-4000-8000-000000000061', + '00000000-0000-4000-8000-000000000052', + '00000000-0000-4000-8000-000000000011', + 'Is handwritten format required or can we submit typed PDF reports?', + false + ), + ( + '00000000-0000-4000-8000-000000000062', + '00000000-0000-4000-8000-000000000052', + '00000000-0000-4000-8000-000000000010', + 'Typed PDF submissions with query execution outputs are accepted.', + true + ) +ON CONFLICT (id) DO NOTHING; diff --git a/tests/unit/clipboard.test.ts b/tests/unit/clipboard.test.ts new file mode 100644 index 0000000..cf8d819 --- /dev/null +++ b/tests/unit/clipboard.test.ts @@ -0,0 +1,114 @@ +import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest'; +import { copyToClipboard } from '../../src/lib/utils/clipboard'; +import { toast } from 'sonner'; + +vi.mock('sonner', () => ({ + toast: { + success: vi.fn(), + error: vi.fn(), + info: vi.fn(), + warning: vi.fn(), + }, +})); + +describe('Resilient copyToClipboard utility', () => { + const originalClipboard = navigator.clipboard; + const originalExecCommand = document.execCommand; + + beforeEach(() => { + vi.clearAllMocks(); + }); + + afterEach(() => { + Object.defineProperty(navigator, 'clipboard', { + value: originalClipboard, + writable: true, + configurable: true, + }); + document.execCommand = originalExecCommand; + }); + + it('copies text successfully using navigator.clipboard.writeText', async () => { + const writeTextMock = vi.fn().mockResolvedValue(undefined); + Object.defineProperty(navigator, 'clipboard', { + value: { writeText: writeTextMock }, + writable: true, + configurable: true, + }); + + const result = await copyToClipboard('22ESKCS099', { + successMessage: 'Roll number copied!', + }); + + expect(result).toBe(true); + expect(writeTextMock).toHaveBeenCalledWith('22ESKCS099'); + expect(toast.success).toHaveBeenCalledWith('Roll number copied!'); + expect(toast.error).not.toHaveBeenCalled(); + }); + + it('falls back to document.execCommand when navigator.clipboard throws', async () => { + const writeTextMock = vi.fn().mockRejectedValue(new Error('Permission denied')); + Object.defineProperty(navigator, 'clipboard', { + value: { writeText: writeTextMock }, + writable: true, + configurable: true, + }); + + const execCommandMock = vi.fn().mockReturnValue(true); + document.execCommand = execCommandMock; + + const result = await copyToClipboard('104', { + successMessage: 'Class roll copied!', + }); + + expect(result).toBe(true); + expect(writeTextMock).toHaveBeenCalledWith('104'); + expect(execCommandMock).toHaveBeenCalledWith('copy'); + expect(toast.success).toHaveBeenCalledWith('Class roll copied!'); + expect(toast.error).not.toHaveBeenCalled(); + }); + + it('falls back to document.execCommand when navigator.clipboard is undefined', async () => { + Object.defineProperty(navigator, 'clipboard', { + value: undefined, + writable: true, + configurable: true, + }); + + const execCommandMock = vi.fn().mockReturnValue(true); + document.execCommand = execCommandMock; + + const result = await copyToClipboard('P2WXYZ', { + successMessage: 'Invite code copied!', + }); + + expect(result).toBe(true); + expect(execCommandMock).toHaveBeenCalledWith('copy'); + expect(toast.success).toHaveBeenCalledWith('Invite code copied!'); + }); + + it('displays error toast when both clipboard and execCommand fail', async () => { + const writeTextMock = vi.fn().mockRejectedValue(new Error('NotAllowedError')); + Object.defineProperty(navigator, 'clipboard', { + value: { writeText: writeTextMock }, + writable: true, + configurable: true, + }); + + const execCommandMock = vi.fn().mockReturnValue(false); + document.execCommand = execCommandMock; + + const result = await copyToClipboard('FailedText', { + errorMessage: 'Clipboard permission denied', + }); + + expect(result).toBe(false); + expect(toast.error).toHaveBeenCalledWith('Clipboard permission denied'); + }); + + it('returns false and displays error toast when empty string is provided', async () => { + const result = await copyToClipboard(''); + expect(result).toBe(false); + expect(toast.error).toHaveBeenCalledWith('Clipboard permission denied'); + }); +}); diff --git a/tests/unit/commentsCrud.test.ts b/tests/unit/commentsCrud.test.ts index d746e58..6f4f7ee 100644 --- a/tests/unit/commentsCrud.test.ts +++ b/tests/unit/commentsCrud.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from "vitest"; +import { commentSchema, MAX_COMMENT_LENGTH } from "../../src/lib/validation/comments.schema"; // The validation logic matching AnnouncementCommentsDrawer's inline canEdit check export function evaluateCanEdit({ @@ -98,3 +99,45 @@ describe("Q&A Comment Editing Validation Logic", () => { ).toBe(false); }); }); + +describe("Q&A Comment Length Boundary Validation", () => { + it("exports MAX_COMMENT_LENGTH as 500", () => { + expect(MAX_COMMENT_LENGTH).toBe(500); + }); + + it("rejects 0 characters (empty string)", () => { + const result = commentSchema.safeParse({ content: "" }); + expect(result.success).toBe(false); + }); + + it("rejects whitespace-only string", () => { + const result = commentSchema.safeParse({ content: " \n\t " }); + expect(result.success).toBe(false); + }); + + it("accepts exactly 1 character", () => { + const result = commentSchema.safeParse({ content: "A" }); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.content).toBe("A"); + } + }); + + it("accepts exactly 500 characters (max boundary)", () => { + const fiveHundredChars = "x".repeat(500); + const result = commentSchema.safeParse({ content: fiveHundredChars }); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.content.length).toBe(500); + } + }); + + it("rejects 501 characters (max + 1 boundary violation)", () => { + const fiveHundredAndOneChars = "x".repeat(501); + const result = commentSchema.safeParse({ content: fiveHundredAndOneChars }); + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.issues[0]?.message).toContain("500"); + } + }); +}); diff --git a/tests/unit/components.test.tsx b/tests/unit/components.test.tsx index 753eb58..8e68248 100644 --- a/tests/unit/components.test.tsx +++ b/tests/unit/components.test.tsx @@ -101,4 +101,86 @@ describe("Radix & Motion Components", () => { expect(screen.getByText("Sheet Body Content")).toBeDefined(); }); }); + + describe("PDF Viewer Keyboard Navigation", () => { + it("dispatches page turn actions on ArrowRight and ArrowDown", () => { + const onNext = vi.fn(); + const onPrev = vi.fn(); + const onEscape = vi.fn(); + + const handleKeyDown = (e: KeyboardEvent) => { + const target = e.target as HTMLElement | null; + const tagName = target?.tagName?.toUpperCase(); + if (tagName === "INPUT" || tagName === "TEXTAREA" || tagName === "SELECT" || target?.isContentEditable) { + return; + } + if (e.key === "ArrowRight" || e.key === "ArrowDown") { + e.preventDefault(); + onNext(); + } else if (e.key === "ArrowLeft" || e.key === "ArrowUp") { + e.preventDefault(); + onPrev(); + } else if (e.key === "Escape") { + e.preventDefault(); + onEscape(); + } + }; + + window.addEventListener("keydown", handleKeyDown); + + window.dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowRight" })); + expect(onNext).toHaveBeenCalledTimes(1); + + window.dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowDown" })); + expect(onNext).toHaveBeenCalledTimes(2); + + window.dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowLeft" })); + expect(onPrev).toHaveBeenCalledTimes(1); + + window.dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowUp" })); + expect(onPrev).toHaveBeenCalledTimes(2); + + window.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape" })); + expect(onEscape).toHaveBeenCalledTimes(1); + + window.removeEventListener("keydown", handleKeyDown); + }); + + it("ignores navigation keys when typing in an input or textarea element", () => { + const onNext = vi.fn(); + + const handleKeyDown = (e: KeyboardEvent) => { + const target = e.target as HTMLElement | null; + const tagName = target?.tagName?.toUpperCase(); + if (tagName === "INPUT" || tagName === "TEXTAREA" || tagName === "SELECT" || target?.isContentEditable) { + return; + } + if (e.key === "ArrowRight" || e.key === "ArrowDown") { + e.preventDefault(); + onNext(); + } + }; + + window.addEventListener("keydown", handleKeyDown); + + // Create an input and dispatch from it + const input = document.createElement("input"); + document.body.appendChild(input); + input.focus(); + + input.dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowRight", bubbles: true })); + expect(onNext).not.toHaveBeenCalled(); + + const textarea = document.createElement("textarea"); + document.body.appendChild(textarea); + textarea.focus(); + + textarea.dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowDown", bubbles: true })); + expect(onNext).not.toHaveBeenCalled(); + + document.body.removeChild(input); + document.body.removeChild(textarea); + window.removeEventListener("keydown", handleKeyDown); + }); + }); });