From 942780db1c0fc867a3354187ea789554663292aa Mon Sep 17 00:00:00 2001 From: Nurfog Date: Mon, 12 Jan 2026 00:52:26 -0300 Subject: [PATCH] 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 --- ...260111000006_add_org_to_content_tables.sql | 37 ++++++ ...260111000007_fix_audit_logs_constraint.sql | 95 +++++++++++++++ ...0260111000008_fix_content_schema_final.sql | 28 +++++ .../20260111000009_seed_tutorials.sql | 111 ++++++++++++++++++ services/cms-service/src/handlers.rs | 28 ++++- web/experience/Dockerfile | 7 +- web/experience/src/app/auth/login/page.tsx | 8 +- web/experience/src/app/layout.tsx | 21 ++-- web/experience/src/components/AuthGuard.tsx | 37 ++++++ web/studio/Dockerfile | 7 +- .../src/app/admin/organizations/page.tsx | 8 +- .../courses/[id]/analytics/advanced/page.tsx | 12 +- .../src/app/courses/[id]/calendar/page.tsx | 25 ++-- .../src/app/courses/[id]/grading/page.tsx | 16 ++- .../courses/[id]/lessons/[lessonId]/page.tsx | 42 +++---- web/studio/src/app/layout.tsx | 23 ++-- web/studio/src/app/settings/webhooks/page.tsx | 16 +-- web/studio/src/components/AuthGuard.tsx | 37 ++++++ web/studio/src/lib/api.ts | 10 ++ 19 files changed, 476 insertions(+), 92 deletions(-) create mode 100644 services/cms-service/migrations/20260111000006_add_org_to_content_tables.sql create mode 100644 services/cms-service/migrations/20260111000007_fix_audit_logs_constraint.sql create mode 100644 services/cms-service/migrations/20260111000008_fix_content_schema_final.sql create mode 100644 services/cms-service/migrations/20260111000009_seed_tutorials.sql create mode 100644 web/experience/src/components/AuthGuard.tsx create mode 100644 web/studio/src/components/AuthGuard.tsx diff --git a/services/cms-service/migrations/20260111000006_add_org_to_content_tables.sql b/services/cms-service/migrations/20260111000006_add_org_to_content_tables.sql new file mode 100644 index 0000000..d72ba77 --- /dev/null +++ b/services/cms-service/migrations/20260111000006_add_org_to_content_tables.sql @@ -0,0 +1,37 @@ +-- Migration: Add organization_id to content tables +-- Scope: modules, lessons, grading_categories + +-- 1. Add organization_id to modules +ALTER TABLE modules ADD COLUMN IF NOT EXISTS organization_id UUID REFERENCES organizations(id) ON DELETE CASCADE; + +-- Backfill modules based on course +UPDATE modules m SET organization_id = c.organization_id +FROM courses c WHERE m.course_id = c.id AND m.organization_id IS NULL; + +-- Make organization_id NOT NULL after backfill +ALTER TABLE modules ALTER COLUMN organization_id SET NOT NULL; + +-- 2. Add organization_id to lessons +-- Note: lessons are children of modules, which now have organization_id +ALTER TABLE lessons ADD COLUMN IF NOT EXISTS organization_id UUID REFERENCES organizations(id) ON DELETE CASCADE; + +-- Backfill lessons based on module +UPDATE lessons l SET organization_id = m.organization_id +FROM modules m WHERE l.module_id = m.id AND l.organization_id IS NULL; + +-- Make organization_id NOT NULL after backfill +ALTER TABLE lessons ALTER COLUMN organization_id SET NOT NULL; + +-- 3. Add organization_id to grading_categories +ALTER TABLE grading_categories ADD COLUMN IF NOT EXISTS organization_id UUID REFERENCES organizations(id) ON DELETE CASCADE; + +-- Backfill grading_categories based on course +UPDATE grading_categories g SET organization_id = c.organization_id +FROM courses c WHERE g.course_id = c.id AND g.organization_id IS NULL; + +-- Make organization_id NOT NULL after backfill +ALTER TABLE grading_categories ALTER COLUMN organization_id SET NOT NULL; + +-- 4. Re-create triggers to ensure they pick up the new columns for audit logs +-- (Triggers were already defined in 20260111000002_advanced_auditing.sql) +-- No changes needed to the triggers themselves as they use NEW.organization_id dynamically. diff --git a/services/cms-service/migrations/20260111000007_fix_audit_logs_constraint.sql b/services/cms-service/migrations/20260111000007_fix_audit_logs_constraint.sql new file mode 100644 index 0000000..85637f4 --- /dev/null +++ b/services/cms-service/migrations/20260111000007_fix_audit_logs_constraint.sql @@ -0,0 +1,95 @@ +-- Migration: Fix Audit Logs Constraint and Trigger +-- 1. Make changes column nullable (legacy compatibility) +ALTER TABLE audit_logs ALTER COLUMN changes DROP NOT NULL; + +-- 2. Update fn_trigger_audit_log to populate changes if needed or just handle NULLs +CREATE OR REPLACE FUNCTION fn_trigger_audit_log() +RETURNS TRIGGER AS $$ +DECLARE + v_user_id UUID; + v_org_id UUID; + v_ip INET; + v_user_agent TEXT; + v_event_type VARCHAR(50); + v_old_data JSONB := NULL; + v_new_data JSONB := NULL; + v_action VARCHAR(50); +BEGIN + -- Try to get context from session variables + BEGIN + v_user_id := current_setting('app.current_user_id', true)::UUID; + EXCEPTION WHEN OTHERS THEN + v_user_id := NULL; + END; + + BEGIN + v_org_id := current_setting('app.current_org_id', true)::UUID; + EXCEPTION WHEN OTHERS THEN + v_org_id := NULL; + END; + + BEGIN + v_ip := current_setting('app.client_ip', true)::INET; + EXCEPTION WHEN OTHERS THEN + v_ip := NULL; + END; + + BEGIN + v_user_agent := current_setting('app.user_agent', true); + EXCEPTION WHEN OTHERS THEN + v_user_agent := NULL; + END; + + BEGIN + v_event_type := current_setting('app.event_type', true); + EXCEPTION WHEN OTHERS THEN + v_event_type := 'USER_EVENT'; + END; + + -- Handle different operations + IF (TG_OP = 'DELETE') THEN + v_old_data := to_jsonb(OLD); + v_action := 'DELETE'; + ELSIF (TG_OP = 'UPDATE') THEN + v_old_data := to_jsonb(OLD); + v_new_data := to_jsonb(NEW); + v_action := 'UPDATE'; + ELSIF (TG_OP = 'INSERT') THEN + v_new_data := to_jsonb(NEW); + v_action := 'INSERT'; + END IF; + + -- Insert into audit_logs + INSERT INTO audit_logs ( + organization_id, + user_id, + action, + entity_type, + entity_id, + event_type, + old_data, + new_data, + ip_address, + user_agent, + changes -- Populate legacy column with new_data or old_data for compatibility + ) + VALUES ( + COALESCE(v_org_id, (CASE WHEN TG_OP = 'DELETE' THEN OLD.organization_id ELSE NEW.organization_id END)), + v_user_id, + v_action, + TG_TABLE_NAME, + CASE WHEN TG_OP = 'DELETE' THEN OLD.id ELSE NEW.id END, + COALESCE(v_event_type, 'USER_EVENT'), + v_old_data, + v_new_data, + v_ip, + v_user_agent, + COALESCE(v_new_data, v_old_data) + ); + + IF (TG_OP = 'DELETE') THEN + RETURN OLD; + END IF; + RETURN NEW; +END; +$$ LANGUAGE plpgsql; diff --git a/services/cms-service/migrations/20260111000008_fix_content_schema_final.sql b/services/cms-service/migrations/20260111000008_fix_content_schema_final.sql new file mode 100644 index 0000000..7f1d96f --- /dev/null +++ b/services/cms-service/migrations/20260111000008_fix_content_schema_final.sql @@ -0,0 +1,28 @@ +-- Migration: Final Content Schema Alignment +-- Scope: lessons, modules + +-- 1. Add missing columns to lessons +ALTER TABLE lessons ADD COLUMN IF NOT EXISTS content_blocks JSONB DEFAULT '[]'; +ALTER TABLE lessons ADD COLUMN IF NOT EXISTS updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(); + +-- 2. Add missing columns to modules +ALTER TABLE modules ADD COLUMN IF NOT EXISTS updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(); + +-- 3. Create Update Trigger for updated_at (if not already exists) +-- Function update_updated_at_column was defined in initial_schema.sql + +-- Attach triggers to lessons and modules +DO $$ +BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_trigger WHERE tgname = 'trg_lessons_updated_at') THEN + CREATE TRIGGER trg_lessons_updated_at + BEFORE UPDATE ON lessons + FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); + END IF; + + IF NOT EXISTS (SELECT 1 FROM pg_trigger WHERE tgname = 'trg_modules_updated_at') THEN + CREATE TRIGGER trg_modules_updated_at + BEFORE UPDATE ON modules + FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); + END IF; +END $$; diff --git a/services/cms-service/migrations/20260111000009_seed_tutorials.sql b/services/cms-service/migrations/20260111000009_seed_tutorials.sql new file mode 100644 index 0000000..0c548df --- /dev/null +++ b/services/cms-service/migrations/20260111000009_seed_tutorials.sql @@ -0,0 +1,111 @@ +-- Migration: Seed Tutorials +-- Description: Creates default courses for Admin, Instructor, and Student roles using system functions. + +DO $$ +DECLARE + v_admin_id UUID; + v_org_id UUID; + v_course_id UUID; + v_module_id UUID; + v_lesson_id UUID; + v_blocks JSONB; +BEGIN + -- 1. Get an Author (First Admin) + SELECT id, organization_id INTO v_admin_id, v_org_id FROM users WHERE role = 'admin' LIMIT 1; + + IF v_admin_id IS NULL THEN + RAISE NOTICE 'No admin user found. Skipping tutorial seeding.'; + RETURN; + END IF; + + -- SIMULATE SESSION for Audit Logs + -- This ensures fn_trigger_audit_log finds a user_id + PERFORM set_config('app.current_user_id', v_admin_id::TEXT, true); + PERFORM set_config('app.org_id', v_org_id::TEXT, true); + PERFORM set_config('app.event_type', 'SYSTEM_SEED', true); + + --------------------------------------------------------------------------- + -- COURSE 1: SYSTEM ADMINISTRATION + --------------------------------------------------------------------------- + -- Check if exists to avoid duplicates (simple check by title) + PERFORM id FROM courses WHERE title = 'System Administration 101' AND organization_id = v_org_id; + IF NOT FOUND THEN + SELECT id INTO v_course_id FROM fn_create_course(v_org_id, v_admin_id, 'System Administration 101', 'self_paced'); + + -- Module 1: Organization & Users + SELECT id INTO v_module_id FROM fn_create_module(v_org_id, v_course_id, 'Organization & User Management', 1); + + -- Lesson 1.1: Managing Users + v_blocks := '[ + {"id": "1", "type": "description", "content": "Learn how to invite, manage, and remove users from your organization."}, + {"id": "2", "type": "media", "media_type": "video", "title": "User Management Demo", "url": "https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.mp4"} + ]'; + SELECT id INTO v_lesson_id FROM fn_create_lesson(v_org_id, v_module_id, 'Managing Users & Roles', 'video', NULL, 1, NULL, jsonb_build_object('blocks', v_blocks)); + + -- Lesson 1.2: Audit Logs + v_blocks := '[ + {"id": "1", "type": "description", "content": "OpenCCB automatically logs all critical actions. Access the Audit Log from the settings menu to view a timeline of all system changes."} + ]'; + SELECT id INTO v_lesson_id FROM fn_create_lesson(v_org_id, v_module_id, 'Using the Audit Log', 'article', NULL, 2, NULL, jsonb_build_object('blocks', v_blocks)); + + -- Module 2: System Settings + SELECT id INTO v_module_id FROM fn_create_module(v_org_id, v_course_id, 'System Integrations', 2); + + -- Lesson 2.1: Webhooks + v_blocks := '[ + {"id": "1", "type": "description", "content": "Configure webhooks to receive real-time updates for student enrollments and completions."} + ]'; + SELECT id INTO v_lesson_id FROM fn_create_lesson(v_org_id, v_module_id, 'Configuring Webhooks', 'article', NULL, 1, NULL, jsonb_build_object('blocks', v_blocks)); + END IF; + + --------------------------------------------------------------------------- + -- COURSE 2: INSTRUCTOR ESSENTIALS + --------------------------------------------------------------------------- + PERFORM id FROM courses WHERE title = 'Instructor Essentials' AND organization_id = v_org_id; + IF NOT FOUND THEN + SELECT id INTO v_course_id FROM fn_create_course(v_org_id, v_admin_id, 'Instructor Essentials', 'self_paced'); + + -- Module 1: The Lesson Builder + SELECT id INTO v_module_id FROM fn_create_module(v_org_id, v_course_id, 'Mastering the Lesson Builder', 1); + + -- Lesson 1.1: Content Blocks + v_blocks := '[ + {"id": "1", "type": "description", "content": "The Lesson Builder uses a block-based approach. You can mix and match text, video, and quizzes."}, + {"id": "2", "type": "fill-in-the-blanks", "title": "Knowledge Check", "content": "The [[Lesson]] Builder allows you to create [[engaging]] content."} + ]'; + SELECT id INTO v_lesson_id FROM fn_create_lesson(v_org_id, v_module_id, 'Content Blocks Deep Dive', 'activity', NULL, 1, NULL, jsonb_build_object('blocks', v_blocks)); + + -- Module 2: Grading + SELECT id INTO v_module_id FROM fn_create_module(v_org_id, v_course_id, 'Grading & Analytics', 2); + + -- Lesson 2.1: Grading Policy + v_blocks := '[ + {"id": "1", "type": "description", "content": "Set up grading categories like Exams and Assignments to weight student performance."} + ]'; + SELECT id INTO v_lesson_id FROM fn_create_lesson(v_org_id, v_module_id, 'Setting up Grading Policies', 'article', NULL, 1, NULL, jsonb_build_object('blocks', v_blocks)); + END IF; + + --------------------------------------------------------------------------- + -- COURSE 3: STUDENT EXPERIENCE + --------------------------------------------------------------------------- + PERFORM id FROM courses WHERE title = 'Student Onboarding' AND organization_id = v_org_id; + IF NOT FOUND THEN + SELECT id INTO v_course_id FROM fn_create_course(v_org_id, v_admin_id, 'Student Onboarding', 'self_paced'); + + -- Module 1: Getting Started + SELECT id INTO v_module_id FROM fn_create_module(v_org_id, v_course_id, 'Welcome to OpenCCB', 1); + + -- Lesson 1.1: Navigation + v_blocks := '[ + {"id": "1", "type": "description", "content": "Navigate through your courses using the sidebar. Track your progress on the dashboard."} + ]'; + SELECT id INTO v_lesson_id FROM fn_create_lesson(v_org_id, v_module_id, 'Platform Navigation', 'video', 'https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/TearsOfSteel.mp4', 1, NULL, jsonb_build_object('blocks', v_blocks)); + + -- Lesson 1.2: Gamification + v_blocks := '[ + {"id": "1", "type": "description", "content": "Earn XP and badges as you complete lessons. Check the leaderboard to see how you stack up!"} + ]'; + SELECT id INTO v_lesson_id FROM fn_create_lesson(v_org_id, v_module_id, 'Understanding XP & Badges', 'article', NULL, 2, NULL, jsonb_build_object('blocks', v_blocks)); + END IF; + +END $$; diff --git a/services/cms-service/src/handlers.rs b/services/cms-service/src/handlers.rs index 19a27e5..ea0ff2f 100644 --- a/services/cms-service/src/handlers.rs +++ b/services/cms-service/src/handlers.rs @@ -262,6 +262,22 @@ pub async fn update_course( .and_then(|s| s.parse::>().ok()) .or(existing.end_date); + // BEGIN TRANSACTION + let mut tx = pool + .begin() + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + + // Set auditing context + sqlx::query( + "SELECT set_config('app.current_user_id', $1, true), set_config('app.org_id', $2, true)", + ) + .bind(claims.sub.to_string()) + .bind(org_ctx.id.to_string()) + .execute(&mut *tx) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + let course = sqlx::query_as::<_, Course>( "SELECT * FROM fn_update_course($1, $2, $3, $4, $5, $6, $7, $8, $9)", ) @@ -274,7 +290,7 @@ pub async fn update_course( .bind(start_date) .bind(end_date) .bind(certificate_template) - .fetch_one(&pool) + .fetch_one(&mut *tx) .await .map_err(|e| { ( @@ -283,6 +299,10 @@ pub async fn update_course( ) })?; + tx.commit() + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + Ok(Json(course)) } @@ -944,13 +964,15 @@ pub async fn get_grading_categories( pub async fn create_grading_category( State(pool): State, + Org(org_ctx): Org, Json(payload): Json, ) -> Result, (StatusCode, String)> { let category = sqlx::query_as::<_, common::models::GradingCategory>( - "INSERT INTO grading_categories (course_id, name, weight, drop_count) - VALUES ($1, $2, $3, $4) + "INSERT INTO grading_categories (organization_id, course_id, name, weight, drop_count) + VALUES ($1, $2, $3, $4, $5) RETURNING *", ) + .bind(org_ctx.id) .bind(payload.course_id) .bind(payload.name) .bind(payload.weight) diff --git a/web/experience/Dockerfile b/web/experience/Dockerfile index 50d7c1b..a386fd1 100644 --- a/web/experience/Dockerfile +++ b/web/experience/Dockerfile @@ -14,12 +14,15 @@ COPY web/experience/ . 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 LMS binary COPY --from=rust-builder /usr/src/app/target/release/lms-service ./ diff --git a/web/experience/src/app/auth/login/page.tsx b/web/experience/src/app/auth/login/page.tsx index 9df006f..a4c7fff 100644 --- a/web/experience/src/app/auth/login/page.tsx +++ b/web/experience/src/app/auth/login/page.tsx @@ -3,10 +3,12 @@ import React, { useState } from "react"; import { useRouter } from "next/navigation"; import { lmsApi } from "@/lib/api"; +import { useAuth } from "@/context/AuthContext"; import { GraduationCap, Lock, Mail, User, Building2 } from "lucide-react"; export default function ExperienceLoginPage() { const router = useRouter(); + const { login } = useAuth(); const [isLogin, setIsLogin] = useState(true); const [email, setEmail] = useState(""); const [password, setPassword] = useState(""); @@ -31,8 +33,7 @@ export default function ExperienceLoginPage() { return; } - localStorage.setItem("experience_token", response.token); - localStorage.setItem("experience_user", JSON.stringify(response.user)); + login(response.user, response.token); router.push("/"); } else { const response = await lmsApi.register({ @@ -42,8 +43,7 @@ export default function ExperienceLoginPage() { organization_name: organizationName, }); - localStorage.setItem("experience_token", response.token); - localStorage.setItem("experience_user", JSON.stringify(response.user)); + login(response.user, response.token); router.push("/"); } } catch (err) { diff --git a/web/experience/src/app/layout.tsx b/web/experience/src/app/layout.tsx index 5e10165..a4411da 100644 --- a/web/experience/src/app/layout.tsx +++ b/web/experience/src/app/layout.tsx @@ -4,6 +4,7 @@ import "./globals.css"; import Link from "next/link"; import { AuthProvider } from "@/context/AuthContext"; import { BrandingProvider } from "@/context/BrandingContext"; +import AuthGuard from "@/components/AuthGuard"; const inter = Inter({ subsets: ["latin"] }); @@ -24,15 +25,17 @@ export default function RootLayout({ - -
- {children} -
-
-

- Powered by OpenCCB © 2023. Advanced Agentic Coding. -

-
+ + +
+ {children} +
+
+

+ Powered by OpenCCB © 2023. Advanced Agentic Coding. +

+
+
diff --git a/web/experience/src/components/AuthGuard.tsx b/web/experience/src/components/AuthGuard.tsx new file mode 100644 index 0000000..d57fc3a --- /dev/null +++ b/web/experience/src/components/AuthGuard.tsx @@ -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 ( +
+
+
+ ); + } + + const isAuthPage = pathname?.startsWith("/auth"); + if (!user && !isAuthPage) { + return null; + } + + return <>{children}; +} diff --git a/web/studio/Dockerfile b/web/studio/Dockerfile index 0bd5354..a5060c1 100644 --- a/web/studio/Dockerfile +++ b/web/studio/Dockerfile @@ -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 ./ diff --git a/web/studio/src/app/admin/organizations/page.tsx b/web/studio/src/app/admin/organizations/page.tsx index be8738f..02b8f00 100644 --- a/web/studio/src/app/admin/organizations/page.tsx +++ b/web/studio/src/app/admin/organizations/page.tsx @@ -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() {
{org.logo_url ? ( - {org.name} + {org.name} ) : ( )} @@ -259,7 +259,7 @@ export default function OrganizationsPage() {
{selectedOrg.logo_url ? ( - Preview + Preview ) : ( )} @@ -323,7 +323,7 @@ export default function OrganizationsPage() {
{selectedOrg.logo_url ? ( - Logo + Logo ) :
}
diff --git a/web/studio/src/app/courses/[id]/analytics/advanced/page.tsx b/web/studio/src/app/courses/[id]/analytics/advanced/page.tsx index c3a4054..608bca5 100644 --- a/web/studio/src/app/courses/[id]/analytics/advanced/page.tsx +++ b/web/studio/src/app/courses/[id]/analytics/advanced/page.tsx @@ -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() {
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}%` }} /> diff --git a/web/studio/src/app/courses/[id]/calendar/page.tsx b/web/studio/src/app/courses/[id]/calendar/page.tsx index 82af9ab..041a59a 100644 --- a/web/studio/src/app/courses/[id]/calendar/page.tsx +++ b/web/studio/src/app/courses/[id]/calendar/page.tsx @@ -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(
); + days.push(
); } // 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 => (
{lesson.title} @@ -172,10 +169,10 @@ export default function CourseCalendarPage({ params }: { params: { id: string }
-
+
{lesson.important_date_type || 'Activity'}
{lesson.title}
diff --git a/web/studio/src/app/courses/[id]/grading/page.tsx b/web/studio/src/app/courses/[id]/grading/page.tsx index e829e81..abac77b 100644 --- a/web/studio/src/app/courses/[id]/grading/page.tsx +++ b/web/studio/src/app/courses/[id]/grading/page.tsx @@ -163,14 +163,18 @@ export default function GradingPolicyPage() {
- - Assessment Type +
diff --git a/web/studio/src/app/courses/[id]/lessons/[lessonId]/page.tsx b/web/studio/src/app/courses/[id]/lessons/[lessonId]/page.tsx index bded7f6..0c09df2 100644 --- a/web/studio/src/app/courses/[id]/lessons/[lessonId]/page.tsx +++ b/web/studio/src/app/courses/[id]/lessons/[lessonId]/page.tsx @@ -193,7 +193,7 @@ export default function LessonEditor({ params }: { params: { id: string; lessonI
- Outline + Outline / Activity
@@ -265,28 +265,24 @@ export default function LessonEditor({ params }: { params: { id: string; lessonI {isGraded && ( <> -
- {gradingCategories.length === 0 ? ( -
- No grading categories defined. Go to Grading Policy -
- ) : ( - gradingCategories.map((cat) => ( - - )) - )} -
+
+ Assessment Category + +
+ Manage categories in Grading Policy +
+
diff --git a/web/studio/src/app/layout.tsx b/web/studio/src/app/layout.tsx index ffb8055..1b95f27 100644 --- a/web/studio/src/app/layout.tsx +++ b/web/studio/src/app/layout.tsx @@ -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({ -
- -
- -
- STUDIO - - -
-
{children}
+ +
+ +
+ +
+ STUDIO + + +
+
{children}
+
diff --git a/web/studio/src/app/settings/webhooks/page.tsx b/web/studio/src/app/settings/webhooks/page.tsx index ad12943..c7be24d 100644 --- a/web/studio/src/app/settings/webhooks/page.tsx +++ b/web/studio/src/app/settings/webhooks/page.tsx @@ -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' }`} >
diff --git a/web/studio/src/components/AuthGuard.tsx b/web/studio/src/components/AuthGuard.tsx new file mode 100644 index 0000000..b5b16ff --- /dev/null +++ b/web/studio/src/components/AuthGuard.tsx @@ -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 ( +
+
+
+ ); + } + + const isAuthPage = pathname?.startsWith("/auth"); + if (!user && !isAuthPage) { + return null; // Prevents flashing protected content + } + + return <>{children}; +} diff --git a/web/studio/src/lib/api.ts b/web/studio/src/lib/api.ts index 0aa8fa8..a098525 100644 --- a/web/studio/src/lib/api.ts +++ b/web/studio/src/lib/api.ts @@ -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;