feat: implement structured grading system with predefined assessment types

- Add structured grading policy with predefined types (Continuous Assessment, Midterm, Final Test, Exam)
- Replace free-text category input with combobox selection in Grading Policy page
- Update Lesson Editor to use dropdown selector for grading category assignment
- Fix create_grading_category handler to capture organization context
- Fix update_course handler to set audit context in database transaction
- Implement getImageUrl helper for proper asset path resolution
- Add unoptimized prop to organization logo images to bypass Next.js optimization
- Add database migrations for organization_id in content tables
- Seed default tutorial courses for Admin, Instructor, and Student roles
- Fix audit log constraints and content schema issues
This commit is contained in:
2026-01-12 00:52:26 -03:00
parent 3ddcaaaf15
commit 942780db1c
19 changed files with 476 additions and 92 deletions
+5 -2
View File
@@ -14,12 +14,15 @@ COPY web/studio/ .
RUN npm run build
# Final stage
FROM node:18-alpine AS runner
FROM node:18-slim AS runner
WORKDIR /app
ENV NODE_ENV production
# Install system dependencies for Rust binary
RUN apk add --no-cache openssl libgcc libstdc++
RUN apt-get update && apt-get install -y openssl ca-certificates && rm -rf /var/lib/apt/lists/*
# Install sharp for Next.js image optimization
RUN npm install sharp
# Copy CMS binary
COPY --from=rust-builder /usr/src/app/target/release/cms-service ./
@@ -1,7 +1,7 @@
'use client';
import { useState, useEffect } from 'react';
import { cmsApi, Organization } from '@/lib/api';
import { cmsApi, Organization, getImageUrl } from '@/lib/api';
import { useAuth } from '@/context/AuthContext';
import Image from 'next/image';
import { Plus, Building2, Globe, Calendar, ExternalLink, ShieldCheck, Palette, Upload, Save, X } from 'lucide-react';
@@ -144,7 +144,7 @@ export default function OrganizationsPage() {
<div className="flex items-start gap-4 mb-4">
<div className="p-3 rounded-lg bg-blue-500/10 text-blue-400 overflow-hidden w-12 h-12 flex items-center justify-center relative">
{org.logo_url ? (
<Image src={org.logo_url} alt={org.name} fill className="object-contain" />
<Image src={getImageUrl(org.logo_url)} alt={org.name} fill className="object-contain" unoptimized />
) : (
<Building2 className="w-6 h-6" />
)}
@@ -259,7 +259,7 @@ export default function OrganizationsPage() {
<div className="flex items-center gap-4">
<div className="w-20 h-20 rounded-xl bg-black/40 border border-white/10 flex items-center justify-center overflow-hidden relative">
{selectedOrg.logo_url ? (
<Image src={selectedOrg.logo_url} alt="Preview" fill className="object-contain" />
<Image src={getImageUrl(selectedOrg.logo_url)} alt="Preview" fill className="object-contain" unoptimized />
) : (
<Building2 className="w-8 h-8 text-gray-600" />
)}
@@ -323,7 +323,7 @@ export default function OrganizationsPage() {
<div className="flex items-center gap-2">
<div className="w-5 h-5 bg-white/20 rounded flex items-center justify-center overflow-hidden relative">
{selectedOrg.logo_url ? (
<Image src={selectedOrg.logo_url} alt="Logo" fill className="object-contain" />
<Image src={getImageUrl(selectedOrg.logo_url)} alt="Logo" fill className="object-contain" unoptimized />
) : <div className="w-3 h-3 bg-white" />}
</div>
<div className="w-16 h-2 bg-white/30 rounded" />
@@ -5,9 +5,6 @@ import { useParams, useRouter } from "next/navigation";
import { cmsApi, Course, AdvancedAnalytics } from "@/lib/api";
import { useAuth } from "@/context/AuthContext";
import {
LineChart,
BarChart3,
Users,
TrendingUp,
ArrowLeft,
Layers,
@@ -35,9 +32,10 @@ export default function AdvancedAnalyticsPage() {
]);
setCourse(courseData);
setAnalytics(advancedData);
} catch (err: any) {
} catch (err: unknown) {
const message = err instanceof Error ? err.message : "Failed to load data";
console.error("Failed to load advanced analytics", err);
setError(err.message || "Failed to load data");
setError(message);
} finally {
setLoading(false);
}
@@ -170,8 +168,8 @@ export default function AdvancedAnalyticsPage() {
<div className="h-4 w-full bg-white/5 rounded-lg overflow-hidden border border-white/5">
<div
className={`h-full transition-all duration-1000 ${percentage > 80 ? 'bg-indigo-500' :
percentage > 50 ? 'bg-indigo-600/70' :
'bg-indigo-700/40'
percentage > 50 ? 'bg-indigo-600/70' :
'bg-indigo-700/40'
}`}
style={{ width: `${percentage}%` }}
/>
@@ -3,12 +3,9 @@ import { useEffect, useState } from "react";
import { cmsApi, Course, Lesson } from "@/lib/api";
import { useRouter } from "next/navigation";
import {
Plus,
Calendar,
ArrowLeft,
ChevronLeft,
ChevronRight,
Clock,
AlertCircle
} from "lucide-react";
import CourseEditorLayout from "@/components/CourseEditorLayout";
@@ -55,12 +52,12 @@ export default function CourseCalendarPage({ params }: { params: { id: string }
// Padding for first week
for (let i = 0; i < firstDay; i++) {
days.push(<div key={`empty - ${i} `} className="h-32 border border-white/5 bg-white/2"></div>);
days.push(<div key={`empty-${i}`} className="h-32 border border-white/5 bg-white/2"></div>);
}
// Days of month
for (let day = 1; day <= daysInMonth; day++) {
const dateStr = `${year} -${String(month + 1).padStart(2, '0')} -${String(day).padStart(2, '0')} `;
const dateStr = `${year}-${String(month + 1).padStart(2, '0')}-${String(day).padStart(2, '0')}`;
const dayLessons = lessons.filter(l => l.due_date && l.due_date.startsWith(dateStr));
days.push(
@@ -70,11 +67,11 @@ export default function CourseCalendarPage({ params }: { params: { id: string }
{dayLessons.map(lesson => (
<div
key={lesson.id}
className={`text - [10px] p - 1 rounded truncate flex items - center gap - 1 ${lesson.important_date_type === 'exam' ? 'bg-red-500/20 text-red-400 border border-red-500/30' :
lesson.important_date_type === 'assignment' ? 'bg-blue-500/20 text-blue-400 border border-blue-500/30' :
lesson.important_date_type === 'live-session' ? 'bg-purple-500/20 text-purple-400 border border-purple-500/30' :
'bg-green-500/20 text-green-400 border border-green-500/30'
} `}
className={`text-[10px] p-1 rounded truncate flex items-center gap-1 ${lesson.important_date_type === 'exam' ? 'bg-red-500/20 text-red-400 border border-red-500/30' :
lesson.important_date_type === 'assignment' ? 'bg-blue-500/20 text-blue-400 border border-blue-500/30' :
lesson.important_date_type === 'live-session' ? 'bg-purple-500/20 text-purple-400 border border-purple-500/30' :
'bg-green-500/20 text-green-400 border border-green-500/30'
}`}
>
<span className="w-1.5 h-1.5 rounded-full bg-current"></span>
{lesson.title}
@@ -172,10 +169,10 @@ export default function CourseCalendarPage({ params }: { params: { id: string }
<div key={lesson.id} className="glass p-4 border-white/5 hover:border-blue-500/30 transition-all group">
<div className="flex justify-between items-start">
<div>
<div className={`text - [10px] font - black uppercase tracking - widest mb - 1 ${lesson.important_date_type === 'exam' ? 'text-red-400' :
lesson.important_date_type === 'assignment' ? 'text-blue-400' :
'text-green-400'
} `}>
<div className={`text-[10px] font-black uppercase tracking-widest mb-1 ${lesson.important_date_type === 'exam' ? 'text-red-400' :
lesson.important_date_type === 'assignment' ? 'text-blue-400' :
'text-green-400'
}`}>
{lesson.important_date_type || 'Activity'}
</div>
<h5 className="font-bold group-hover:text-blue-400 transition-colors">{lesson.title}</h5>
@@ -163,14 +163,18 @@ export default function GradingPolicyPage() {
<div className="space-y-4">
<div>
<label className="text-xs font-semibold text-gray-400 uppercase tracking-widest ml-1">Type Name</label>
<input
type="text"
placeholder="e.g. Quizzes, Final Exam"
<label className="text-xs font-semibold text-gray-400 uppercase tracking-widest ml-1">Assessment Type</label>
<select
value={newName}
onChange={(e) => setNewName(e.target.value)}
className="w-full bg-white/5 border border-white/10 rounded-xl px-4 py-3 mt-1.5 focus:outline-none focus:border-blue-500 transition-all text-gray-100"
/>
className="w-full bg-white/5 border border-white/10 rounded-xl px-4 py-3 mt-1.5 focus:outline-none focus:border-blue-500 transition-all text-gray-100 appearance-none"
>
<option value="" className="bg-gray-900 text-gray-500">Select a type...</option>
<option value="Continuous Assessment" className="bg-gray-900">Continuous Assessment (Min 4)</option>
<option value="Midterm" className="bg-gray-900">Midterm</option>
<option value="Final Test" className="bg-gray-900">Final Test</option>
<option value="Exam" className="bg-gray-900">Exam</option>
</select>
</div>
<div>
@@ -193,7 +193,7 @@ export default function LessonEditor({ params }: { params: { id: string; lessonI
<div className="flex flex-col md:flex-row justify-between items-start md:items-center gap-6 border-b border-white/5 pb-8">
<div className="space-y-1">
<div className="flex items-center gap-2 text-[10px] text-blue-500 font-bold uppercase tracking-[0.2em]">
<Link href={`/ courses / ${params.id} `} className="hover:text-white transition-colors">Outline</Link>
<Link href={`/courses/${params.id}`} className="hover:text-white transition-colors">Outline</Link>
<span className="text-gray-700">/</span>
<span>Activity</span>
</div>
@@ -265,28 +265,24 @@ export default function LessonEditor({ params }: { params: { id: string; lessonI
{isGraded && (
<>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 animate-in zoom-in-95 duration-300">
{gradingCategories.length === 0 ? (
<div className="col-span-full py-4 text-center border border-dashed border-white/10 rounded-2xl text-xs text-gray-500 italic">
No grading categories defined. <Link href={`/ courses / ${params.id} /grading`} className="text-blue-400 underline ml-1">Go to Grading Policy</Link >
</div >
) : (
gradingCategories.map((cat) => (
<button
key={cat.id}
onClick={() => setSelectedCategoryId(cat.id)}
className={`p-4 rounded-2xl border transition-all text-left group ${selectedCategoryId === cat.id
? "bg-blue-500/10 border-blue-500 text-blue-400"
: "bg-white/5 border-white/10 text-gray-500 hover:border-white/20"
}`}
>
<div className="text-[10px] font-bold uppercase tracking-widest opacity-60 mb-1">Category</div>
<div className="font-bold truncate">{cat.name}</div>
<div className="text-xs mt-2 font-medium opacity-80">{cat.weight}% Weight</div>
</button>
))
)}
</div >
<div className="col-span-full space-y-2">
<span className="text-[10px] font-black uppercase tracking-widest text-gray-500 mb-2 block">Assessment Category</span>
<select
value={selectedCategoryId}
onChange={(e) => setSelectedCategoryId(e.target.value)}
className="w-full bg-white/5 border border-white/10 rounded-xl px-4 py-3 text-sm focus:outline-none focus:border-blue-500 transition-all appearance-none font-bold"
>
<option value="" className="bg-gray-900 border-0">Select Category...</option>
{gradingCategories.map((cat) => (
<option key={cat.id} value={cat.id} className="bg-gray-900 border-0">
{cat.name} ({cat.weight}%)
</option>
))}
</select>
<div className="text-[10px] text-gray-500 italic mt-1 pl-1">
Manage categories in <Link href={`/courses/${params.id}/grading`} className="text-blue-400 hover:underline">Grading Policy</Link>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-8 pt-6 border-t border-white/5 animate-in fade-in duration-500">
<div className="space-y-4">
+13 -10
View File
@@ -4,6 +4,7 @@ import "./globals.css";
import Link from "next/link";
import { AuthProvider } from "@/context/AuthContext";
import { BookOpen } from "lucide-react";
import AuthGuard from "@/components/AuthGuard";
const inter = Inter({ subsets: ["latin"] });
@@ -23,16 +24,18 @@ export default function RootLayout({
<html lang="en" className="dark">
<body className={`${inter.className} bg-gray-950 text-gray-200 min-h-screen flex flex-col`}>
<AuthProvider>
<header className="h-20 glass sticky top-0 z-50 px-8 flex items-center justify-between border-b border-white/5 backdrop-blur-xl bg-black/40">
<Link href="/" className="flex items-center gap-3 group">
<div className="w-10 h-10 rounded-xl bg-blue-600 flex items-center justify-center font-black text-white shadow-lg shadow-blue-500/20 group-hover:scale-105 transition-transform">
<BookOpen size={20} />
</div>
<span className="font-black text-2xl tracking-tighter text-white">STUDIO</span>
</Link>
<AuthHeader />
</header>
<main className="flex-1">{children}</main>
<AuthGuard>
<header className="h-20 glass sticky top-0 z-50 px-8 flex items-center justify-between border-b border-white/5 backdrop-blur-xl bg-black/40">
<Link href="/" className="flex items-center gap-3 group">
<div className="w-10 h-10 rounded-xl bg-blue-600 flex items-center justify-center font-black text-white shadow-lg shadow-blue-500/20 group-hover:scale-105 transition-transform">
<BookOpen size={20} />
</div>
<span className="font-black text-2xl tracking-tighter text-white">STUDIO</span>
</Link>
<AuthHeader />
</header>
<main className="flex-1">{children}</main>
</AuthGuard>
</AuthProvider>
</body>
</html>
@@ -43,8 +43,8 @@ export default function WebhooksPage() {
try {
const data = await cmsApi.getWebhooks();
setWebhooks(data);
} catch (err: any) {
setError(err.message);
} catch (err: unknown) {
setError(err instanceof Error ? err.message : "Unknown error");
} finally {
setLoading(false);
}
@@ -58,8 +58,8 @@ export default function WebhooksPage() {
setNewWebhook({ url: '', events: ['course.published'], secret: '' });
setIsAdding(false);
fetchWebhooks();
} catch (err: any) {
setError(err.message);
} catch (err: unknown) {
setError(err instanceof Error ? err.message : "Unknown error");
}
};
@@ -68,8 +68,8 @@ export default function WebhooksPage() {
try {
await cmsApi.deleteWebhook(id);
fetchWebhooks();
} catch (err: any) {
setError(err.message);
} catch (err: unknown) {
setError(err instanceof Error ? err.message : "Unknown error");
}
};
@@ -164,8 +164,8 @@ export default function WebhooksPage() {
id={`event-${event.id}`}
onClick={() => toggleEvent(event.id)}
className={`p-4 rounded-2xl border transition-all cursor-pointer ${newWebhook.events.includes(event.id)
? 'bg-blue-500/20 border-blue-500 text-blue-400 shadow-[0_0_15px_rgba(59,130,246,0.1)]'
: 'bg-black/20 border-white/10 text-gray-400 hover:bg-white/5'
? 'bg-blue-500/20 border-blue-500 text-blue-400 shadow-[0_0_15px_rgba(59,130,246,0.1)]'
: 'bg-black/20 border-white/10 text-gray-400 hover:bg-white/5'
}`}
>
<div className="flex items-center justify-between mb-1">
+37
View File
@@ -0,0 +1,37 @@
"use client";
import { useAuth } from "@/context/AuthContext";
import { useRouter, usePathname } from "next/navigation";
import { useEffect } from "react";
export default function AuthGuard({ children }: { children: React.ReactNode }) {
const { user, loading } = useAuth();
const router = useRouter();
const pathname = usePathname();
useEffect(() => {
if (!loading) {
const isAuthPage = pathname?.startsWith("/auth");
if (!user && !isAuthPage) {
router.push("/auth/login");
} else if (user && isAuthPage) {
router.push("/");
}
}
}, [user, loading, pathname, router]);
if (loading) {
return (
<div className="min-h-screen bg-gray-950 flex items-center justify-center">
<div className="w-12 h-12 border-4 border-blue-500/20 border-t-blue-500 rounded-full animate-spin"></div>
</div>
);
}
const isAuthPage = pathname?.startsWith("/auth");
if (!user && !isAuthPage) {
return null; // Prevents flashing protected content
}
return <>{children}</>;
}
+10
View File
@@ -1,5 +1,15 @@
export const API_BASE_URL = process.env.NEXT_PUBLIC_CMS_API_URL || "http://localhost:3001";
export const getImageUrl = (path?: string) => {
if (!path) return '';
if (path.startsWith('http')) return path;
// Map /uploads to /assets if backend stores relative paths
// The main.rs serves "uploads" dir at "/assets" route
const cleanPath = path.startsWith('/uploads') ? path.replace('/uploads', '/assets') : path;
const finalPath = cleanPath.startsWith('/') ? cleanPath : `/${cleanPath}`;
return `${API_BASE_URL}${finalPath}`;
};
export interface Course {
id: string;
title: string;