eduverse-backend / Documentation /ASSESSMENT_INTEGRATION_GUIDE.md
tarekdx3's picture
Labs - Quizzes - Assignment student Dashboard still under work
1af4e1f
|
Raw
History Blame Contribute Delete
20.8 kB

Assessment Integration Guide

Common patterns and shared components for integrating Quizzes, Assignments, and Labs in the EduVerse frontend.


Table of Contents

  1. Overview
  2. Authentication & Authorization
  3. Common API Patterns
  4. File Upload Integration
  5. Grading Integration
  6. State Management
  7. Shared Components
  8. Error Handling
  9. Real-time Features

Overview

All three assessment modules (Quizzes, Assignments, Labs) share common patterns:

Feature Quizzes Assignments Labs
Status workflow ✅ draft→published→closed→archived
File attachments ❌ (planned) ✅ Google Drive ✅ Google Drive
Auto-grading ✅ MCQ/TF
Manual grading ✅ Essay
Grades table integration
Due dates ✅ availableUntil ✅ dueDate ✅ dueDate
Late handling ✅ latePenalty % ✅ isLate flag
Attendance

Authentication & Authorization

JWT Token Usage

All API requests require a Bearer token:

// api/client.ts
const API_BASE = 'http://localhost:8081';

export async function apiRequest<T>(
  endpoint: string,
  options: RequestInit = {}
): Promise<T> {
  const token = localStorage.getItem('accessToken');
  
  const response = await fetch(`${API_BASE}${endpoint}`, {
    ...options,
    headers: {
      'Content-Type': 'application/json',
      'Authorization': `Bearer ${token}`,
      ...options.headers,
    },
  });

  if (response.status === 401) {
    // Token expired - redirect to login
    localStorage.removeItem('accessToken');
    window.location.href = '/login';
    throw new Error('Unauthorized');
  }

  if (!response.ok) {
    const error = await response.json();
    throw new Error(error.message || 'Request failed');
  }

  return response.json();
}

Login Flow

// POST /api/auth/login
interface LoginDto {
  email: string;
  password: string;
  rememberMe?: boolean;
}

interface LoginResponse {
  accessToken: string;
}

const login = async (credentials: LoginDto): Promise<void> => {
  const response = await fetch('/api/auth/login', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify(credentials),
  });

  if (!response.ok) {
    throw new Error('Invalid credentials');
  }

  const { accessToken } = await response.json();
  localStorage.setItem('accessToken', accessToken);
};

Role-Based UI

// hooks/useAuth.ts
interface User {
  userId: number;
  email: string;
  firstName: string;
  lastName: string;
  roles: Array<{ roleName: string }>;
}

export function useAuth() {
  const [user, setUser] = useState<User | null>(null);

  const hasRole = (role: string) => {
    return user?.roles.some(r => r.roleName === role) ?? false;
  };

  const isInstructor = hasRole('instructor');
  const isStudent = hasRole('student');
  const isTA = hasRole('teaching_assistant');
  const isAdmin = hasRole('admin') || hasRole('it_admin');

  const canManageAssessments = isInstructor || isTA || isAdmin;
  const canGrade = isInstructor || isTA;
  const canSubmit = isStudent;

  return { user, hasRole, isInstructor, isStudent, isTA, canManageAssessments, canGrade, canSubmit };
}

Common API Patterns

Pagination

All list endpoints support pagination:

interface PaginatedResponse<T> {
  data: T[];
  meta: {
    total: number;
    page: number;
    limit: number;
    totalPages: number;
  };
}

// Usage
const getQuizzes = async (page = 1, limit = 10): Promise<PaginatedResponse<Quiz>> => {
  return apiRequest(`/api/quizzes?page=${page}&limit=${limit}`);
};

Filtering

Common query parameters:

Parameter Description Example
courseId Filter by course ?courseId=1
status Filter by status ?status=published
page Page number ?page=2
limit Items per page ?limit=20
search Search text ?search=midterm

Status Change Pattern

All modules use the same status change pattern:

type AssessmentStatus = 'draft' | 'published' | 'closed' | 'archived';

const changeStatus = async (
  type: 'quizzes' | 'assignments' | 'labs',
  id: number,
  status: AssessmentStatus
): Promise<void> => {
  await apiRequest(`/api/${type}/${id}/status`, {
    method: 'PATCH',
    body: JSON.stringify({ status }),
  });
};

File Upload Integration

Google Drive Integration

The backend uses Google Drive for file storage. Two-step upload process:

// Step 1: Upload file to Drive
interface UploadResponse {
  fileId: number;
  fileName: string;
  webViewLink: string;
}

const uploadFile = async (
  type: 'assignments' | 'labs',
  id: number,
  file: File,
  purpose: 'instructions' | 'submissions' | 'ta-materials'
): Promise<UploadResponse> => {
  const formData = new FormData();
  formData.append('file', file);

  const endpoint = purpose === 'instructions'
    ? `/api/${type}/${id}/instructions/upload`
    : purpose === 'ta-materials'
    ? `/api/${type}/${id}/ta-materials/upload`
    : `/api/${type}/${id}/submissions/upload`;

  const response = await fetch(endpoint, {
    method: 'POST',
    headers: {
      'Authorization': `Bearer ${localStorage.getItem('accessToken')}`,
    },
    body: formData,
  });

  return response.json();
};

// Step 2: Submit with file ID
const submitWithFile = async (
  type: 'assignments' | 'labs',
  id: number,
  fileId: number
): Promise<void> => {
  await apiRequest(`/api/${type}/${id}/submit`, {
    method: 'POST',
    body: JSON.stringify({ fileId }),
  });
};

File Upload Component

// components/FileUploader.tsx
import { useState, useCallback } from 'react';

interface FileUploaderProps {
  type: 'assignments' | 'labs';
  id: number;
  purpose: 'instructions' | 'submissions' | 'ta-materials';
  accept?: string;
  maxSize?: number;  // bytes
  onUpload: (fileId: number, fileName: string) => void;
  onError: (error: string) => void;
}

export function FileUploader({
  type,
  id,
  purpose,
  accept = '*/*',
  maxSize = 10 * 1024 * 1024,  // 10MB default
  onUpload,
  onError,
}: FileUploaderProps) {
  const [uploading, setUploading] = useState(false);
  const [progress, setProgress] = useState(0);

  const handleFile = useCallback(async (file: File) => {
    // Validate size
    if (file.size > maxSize) {
      onError(`File too large. Max size: ${formatBytes(maxSize)}`);
      return;
    }

    setUploading(true);
    setProgress(0);

    try {
      const result = await uploadFile(type, id, file, purpose);
      onUpload(result.fileId, result.fileName);
    } catch (err) {
      onError(err instanceof Error ? err.message : 'Upload failed');
    } finally {
      setUploading(false);
    }
  }, [type, id, purpose, maxSize, onUpload, onError]);

  return (
    <div className="file-uploader">
      <input
        type="file"
        accept={accept}
        disabled={uploading}
        onChange={(e) => e.target.files?.[0] && handleFile(e.target.files[0])}
      />
      {uploading && (
        <div className="progress-bar">
          <div className="progress" style={{ width: `${progress}%` }} />
        </div>
      )}
    </div>
  );
}

function formatBytes(bytes: number): string {
  if (bytes < 1024) return `${bytes} B`;
  if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
  return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}

Grading Integration

Central Grades Table

All grades are now stored in the central grades table. When you grade a submission, the backend automatically:

  1. Saves score/feedback to the submission record
  2. Creates/updates a Grade record in the grades table
  3. Calculates percentage and letter grade
// Grade is automatically created when grading:
// - Quiz: After auto-grading or manual grading
// - Assignment: When calling PATCH /grade
// - Lab: When calling PATCH /grade

interface Grade {
  gradeId: number;
  userId: number;
  courseId: number;
  gradeType: 'assignment' | 'quiz' | 'lab' | 'exam' | 'final';
  assignmentId?: number;
  quizId?: number;
  labId?: number;
  score: number;
  maxScore: number;
  percentage: number;
  letterGrade: string;
  feedback?: string;
  isPublished: boolean;
  createdAt: Date;
  updatedAt: Date;
}

Viewing Grades

// GET /api/grades/my - Student's grades
const getMyGrades = async (): Promise<PaginatedResponse<Grade>> => {
  return apiRequest('/api/grades/my');
};

// GET /api/grades?courseId=X - Course grades (instructor)
const getCourseGrades = async (courseId: number): Promise<PaginatedResponse<Grade>> => {
  return apiRequest(`/api/grades?courseId=${courseId}`);
};

// GET /api/grades/transcript/:studentId - Student transcript
const getTranscript = async (studentId: number): Promise<Transcript> => {
  return apiRequest(`/api/grades/transcript/${studentId}`);
};

// GET /api/grades/gpa/:studentId - GPA calculation
const getGPA = async (studentId: number): Promise<GpaResult> => {
  return apiRequest(`/api/grades/gpa/${studentId}`);
};

State Management

Recommended Store Structure (Zustand)

// stores/assessmentStore.ts
import { create } from 'zustand';

interface AssessmentStore {
  // Quizzes
  quizzes: Quiz[];
  loadingQuizzes: boolean;
  fetchQuizzes: (courseId?: number) => Promise<void>;
  
  // Assignments
  assignments: Assignment[];
  loadingAssignments: boolean;
  fetchAssignments: (courseId?: number) => Promise<void>;
  
  // Labs
  labs: Lab[];
  loadingLabs: boolean;
  fetchLabs: (courseId?: number) => Promise<void>;
  
  // Common
  currentCourseId: number | null;
  setCourseId: (id: number) => void;
}

export const useAssessmentStore = create<AssessmentStore>((set, get) => ({
  // Quizzes
  quizzes: [],
  loadingQuizzes: false,
  fetchQuizzes: async (courseId) => {
    set({ loadingQuizzes: true });
    const cid = courseId || get().currentCourseId;
    const { data } = await apiRequest<PaginatedResponse<Quiz>>(
      `/api/quizzes${cid ? `?courseId=${cid}` : ''}`
    );
    set({ quizzes: data, loadingQuizzes: false });
  },
  
  // Assignments
  assignments: [],
  loadingAssignments: false,
  fetchAssignments: async (courseId) => {
    set({ loadingAssignments: true });
    const cid = courseId || get().currentCourseId;
    const { data } = await apiRequest<PaginatedResponse<Assignment>>(
      `/api/assignments${cid ? `?courseId=${cid}` : ''}`
    );
    set({ assignments: data, loadingAssignments: false });
  },
  
  // Labs
  labs: [],
  loadingLabs: false,
  fetchLabs: async (courseId) => {
    set({ loadingLabs: true });
    const cid = courseId || get().currentCourseId;
    const { data } = await apiRequest<PaginatedResponse<Lab>>(
      `/api/labs${cid ? `?courseId=${cid}` : ''}`
    );
    set({ labs: data, loadingLabs: false });
  },
  
  // Common
  currentCourseId: null,
  setCourseId: (id) => set({ currentCourseId: id }),
}));

Caching Strategy

// hooks/useAssessmentCache.ts
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';

// Quizzes
export function useQuizzes(courseId?: number) {
  return useQuery({
    queryKey: ['quizzes', courseId],
    queryFn: () => apiRequest<PaginatedResponse<Quiz>>(
      `/api/quizzes${courseId ? `?courseId=${courseId}` : ''}`
    ),
    staleTime: 5 * 60 * 1000,  // 5 minutes
  });
}

// Single Quiz
export function useQuiz(id: number) {
  return useQuery({
    queryKey: ['quiz', id],
    queryFn: () => apiRequest<Quiz>(`/api/quizzes/${id}`),
  });
}

// Create Quiz
export function useCreateQuiz() {
  const queryClient = useQueryClient();
  
  return useMutation({
    mutationFn: (data: CreateQuizDto) => 
      apiRequest<Quiz>('/api/quizzes', {
        method: 'POST',
        body: JSON.stringify(data),
      }),
    onSuccess: () => {
      queryClient.invalidateQueries({ queryKey: ['quizzes'] });
    },
  });
}

// Update Quiz Status
export function useChangeQuizStatus() {
  const queryClient = useQueryClient();
  
  return useMutation({
    mutationFn: ({ id, status }: { id: number; status: string }) =>
      apiRequest<Quiz>(`/api/quizzes/${id}/status`, {
        method: 'PATCH',
        body: JSON.stringify({ status }),
      }),
    onSuccess: (_, { id }) => {
      queryClient.invalidateQueries({ queryKey: ['quiz', id] });
      queryClient.invalidateQueries({ queryKey: ['quizzes'] });
    },
  });
}

Shared Components

Status Badge

// components/StatusBadge.tsx
type Status = 'draft' | 'published' | 'closed' | 'archived';

const statusColors: Record<Status, string> = {
  draft: 'bg-gray-500',
  published: 'bg-green-500',
  closed: 'bg-yellow-500',
  archived: 'bg-red-500',
};

export function StatusBadge({ status }: { status: Status }) {
  return (
    <span className={`px-2 py-1 rounded text-white text-sm ${statusColors[status]}`}>
      {status.charAt(0).toUpperCase() + status.slice(1)}
    </span>
  );
}

Due Date Display

// components/DueDate.tsx
import { formatDistanceToNow, isPast, isToday, isTomorrow } from 'date-fns';

interface DueDateProps {
  date: Date;
  showCountdown?: boolean;
}

export function DueDate({ date, showCountdown = true }: DueDateProps) {
  const due = new Date(date);
  const overdue = isPast(due);
  const today = isToday(due);
  const tomorrow = isTomorrow(due);

  let label: string;
  let className: string;

  if (overdue) {
    label = 'Overdue';
    className = 'text-red-600 font-bold';
  } else if (today) {
    label = 'Due Today';
    className = 'text-orange-600 font-bold';
  } else if (tomorrow) {
    label = 'Due Tomorrow';
    className = 'text-yellow-600';
  } else {
    label = showCountdown
      ? `Due ${formatDistanceToNow(due, { addSuffix: true })}`
      : due.toLocaleDateString();
    className = 'text-gray-600';
  }

  return (
    <span className={className} title={due.toLocaleString()}>
      {label}
    </span>
  );
}

Grade Display

// components/GradeDisplay.tsx
interface GradeDisplayProps {
  score: number;
  maxScore: number;
  percentage?: number;
  letterGrade?: string;
  showLetter?: boolean;
}

export function GradeDisplay({
  score,
  maxScore,
  percentage,
  letterGrade,
  showLetter = true,
}: GradeDisplayProps) {
  const pct = percentage ?? (score / maxScore) * 100;
  const letter = letterGrade ?? getLetterGrade(pct);

  const getColor = () => {
    if (pct >= 90) return 'text-green-600';
    if (pct >= 80) return 'text-blue-600';
    if (pct >= 70) return 'text-yellow-600';
    if (pct >= 60) return 'text-orange-600';
    return 'text-red-600';
  };

  return (
    <div className={`grade-display ${getColor()}`}>
      <span className="score">{score} / {maxScore}</span>
      <span className="percentage">({pct.toFixed(1)}%)</span>
      {showLetter && <span className="letter">{letter}</span>}
    </div>
  );
}

function getLetterGrade(percentage: number): string {
  if (percentage >= 97) return 'A+';
  if (percentage >= 93) return 'A';
  if (percentage >= 90) return 'A-';
  if (percentage >= 87) return 'B+';
  if (percentage >= 83) return 'B';
  if (percentage >= 80) return 'B-';
  if (percentage >= 77) return 'C+';
  if (percentage >= 73) return 'C';
  if (percentage >= 70) return 'C-';
  if (percentage >= 67) return 'D+';
  if (percentage >= 63) return 'D';
  if (percentage >= 60) return 'D-';
  return 'F';
}

Loading Skeleton

// components/AssessmentSkeleton.tsx
export function AssessmentCardSkeleton() {
  return (
    <div className="assessment-card skeleton">
      <div className="skeleton-line title" />
      <div className="skeleton-line description" />
      <div className="skeleton-line meta" />
    </div>
  );
}

export function AssessmentListSkeleton({ count = 5 }: { count?: number }) {
  return (
    <div className="assessment-list">
      {Array.from({ length: count }).map((_, i) => (
        <AssessmentCardSkeleton key={i} />
      ))}
    </div>
  );
}

Error Handling

Common Error Codes

Status Meaning Action
400 Bad Request Show validation errors
401 Unauthorized Redirect to login
403 Forbidden Show "access denied"
404 Not Found Show "not found" page
409 Conflict Show conflict message
500 Server Error Show generic error

Error Boundary

// components/ErrorBoundary.tsx
import { Component, ErrorInfo, ReactNode } from 'react';

interface Props {
  children: ReactNode;
  fallback?: ReactNode;
}

interface State {
  hasError: boolean;
  error?: Error;
}

export class ErrorBoundary extends Component<Props, State> {
  state: State = { hasError: false };

  static getDerivedStateFromError(error: Error): State {
    return { hasError: true, error };
  }

  componentDidCatch(error: Error, info: ErrorInfo) {
    console.error('Error caught:', error, info);
    // Log to monitoring service
  }

  render() {
    if (this.state.hasError) {
      return this.props.fallback || (
        <div className="error-fallback">
          <h2>Something went wrong</h2>
          <p>{this.state.error?.message}</p>
          <button onClick={() => window.location.reload()}>
            Refresh Page
          </button>
        </div>
      );
    }

    return this.props.children;
  }
}

API Error Hook

// hooks/useApiError.ts
import { useState, useCallback } from 'react';

interface ApiError {
  message: string;
  statusCode: number;
  field?: string;
}

export function useApiError() {
  const [error, setError] = useState<ApiError | null>(null);

  const handleError = useCallback((err: unknown) => {
    if (err instanceof Response) {
      err.json().then(data => {
        setError({
          message: data.message || 'An error occurred',
          statusCode: err.status,
          field: data.field,
        });
      });
    } else if (err instanceof Error) {
      setError({
        message: err.message,
        statusCode: 500,
      });
    }
  }, []);

  const clearError = useCallback(() => setError(null), []);

  return { error, handleError, clearError };
}

Real-time Features

Quiz Timer WebSocket (Future)

// For quiz timers, consider WebSocket connection for:
// - Server-authoritative timer
// - Auto-submit on timeout
// - Sync across devices

interface TimerMessage {
  type: 'TICK' | 'EXPIRED' | 'SYNC';
  attemptId: number;
  remainingSeconds: number;
}

// Example WebSocket setup (to be implemented)
const connectQuizTimer = (attemptId: number, onMessage: (msg: TimerMessage) => void) => {
  const ws = new WebSocket(`ws://localhost:8081/quiz-timer/${attemptId}`);
  
  ws.onmessage = (event) => {
    const message = JSON.parse(event.data);
    onMessage(message);
  };

  return () => ws.close();
};

Notifications

// Poll for notifications (WebSocket integration planned)
const useNotifications = () => {
  const [notifications, setNotifications] = useState<Notification[]>([]);

  useEffect(() => {
    const poll = setInterval(async () => {
      const data = await apiRequest<Notification[]>('/api/notifications/unread');
      setNotifications(data);
    }, 30000);  // Poll every 30 seconds

    return () => clearInterval(poll);
  }, []);

  return notifications;
};

Quick Reference

Endpoint Summary

Module Base URL CRUD Submit Grade Status
Quizzes /api/quizzes POST /attempts/start POST /attempts/:id/grade PATCH /:id/status
Assignments /api/assignments POST /:id/submit PATCH /:id/submissions/:subId/grade PATCH /:id/status
Labs /api/labs POST /:id/submit PATCH /:id/submissions/:subId/grade PATCH /:id/status

Status Values

All three modules use the same status values:

  • draft - Not visible to students
  • published - Available to students
  • closed - No longer accepting submissions
  • archived - Hidden from all views

Test Accounts

Role Email Password
Admin admin.tarek@example.com SecureP@ss123
Instructor instructor.tarek@example.com SecureP@ss123
TA ta.tarek@example.com SecureP@ss123
Student student.tarek@example.com SecureP@ss123