From f695ed7213ef314a1ab3fa8ec5c6bbdc01f783d5 Mon Sep 17 00:00:00 2001 From: Nurfog Date: Tue, 23 Dec 2025 11:04:36 -0300 Subject: [PATCH] feat: implement audit logging and add certificate template field to courses --- .dockerignore | 13 ++ ...0231223000001_add_certificate_template.sql | 2 + services/cms-service/src/handlers.rs | 69 ++++++++- services/cms-service/src/main.rs | 1 + ...0231223000001_add_certificate_template.sql | 2 + services/lms-service/src/handlers.rs | 6 +- shared/common/src/models.rs | 14 ++ .../src/app/courses/[id]/progress/page.tsx | 69 +++++++-- web/experience/src/lib/api.ts | 1 + web/studio/src/app/admin/audit/page.tsx | 137 ++++++++++++++++++ .../src/app/courses/[id]/analytics/page.tsx | 7 - .../src/app/courses/[id]/settings/page.tsx | 76 +++++++++- web/studio/src/app/page.tsx | 31 ++-- web/studio/src/lib/api.ts | 24 +++ 14 files changed, 417 insertions(+), 35 deletions(-) create mode 100644 .dockerignore create mode 100644 services/cms-service/migrations/20231223000001_add_certificate_template.sql create mode 100644 services/lms-service/migrations/20231223000001_add_certificate_template.sql create mode 100644 web/studio/src/app/admin/audit/page.tsx diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..f7910b7 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,13 @@ +target/ +.git/ +node_modules/ +.next/ +web/studio/node_modules/ +web/experience/node_modules/ +web/studio/.next/ +web/experience/.next/ +services/*/target/ +*.log +.env +.DS_Store +.vscode/ diff --git a/services/cms-service/migrations/20231223000001_add_certificate_template.sql b/services/cms-service/migrations/20231223000001_add_certificate_template.sql new file mode 100644 index 0000000..7205b28 --- /dev/null +++ b/services/cms-service/migrations/20231223000001_add_certificate_template.sql @@ -0,0 +1,2 @@ +-- Add certificate_template to courses +ALTER TABLE courses ADD COLUMN certificate_template TEXT; diff --git a/services/cms-service/src/handlers.rs b/services/cms-service/src/handlers.rs index e6b7ad7..2a575dd 100644 --- a/services/cms-service/src/handlers.rs +++ b/services/cms-service/src/handlers.rs @@ -167,17 +167,26 @@ pub async fn update_course( let title = payload.get("title").and_then(|v| v.as_str()).unwrap_or(&existing.title); let description = payload.get("description").and_then(|v| v.as_str()).unwrap_or(existing.description.as_deref().unwrap_or("")); let passing_percentage = payload.get("passing_percentage").and_then(|v| v.as_i64()).unwrap_or(existing.passing_percentage as i64) as i32; + + // Check if certificate_template is in payload (even if null to unset?) + // For simplicity: if provided as string, use it. If not provided, keep existing. + // To unset, user can send empty string maybe? + let certificate_template = payload.get("certificate_template") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()) + .or(existing.certificate_template); let course = sqlx::query_as::<_, Course>( - "UPDATE courses SET title = $1, description = $2, passing_percentage = $3, updated_at = NOW() WHERE id = $4 RETURNING *" + "UPDATE courses SET title = $1, description = $2, passing_percentage = $3, certificate_template = $4, updated_at = NOW() WHERE id = $5 RETURNING *" ) .bind(title) .bind(description) .bind(passing_percentage) + .bind(certificate_template) .bind(id) .fetch_one(&pool) .await - .map_err(|_| (StatusCode::INTERNAL_SERVER_ERROR, "Failed to update course".into()))?; + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Failed to update course: {}", e)))?; Ok(Json(course)) } @@ -673,3 +682,59 @@ pub async fn get_course_analytics( Ok(Json(analytics)) } + +#[derive(Deserialize)] +pub struct AuditQuery { + pub page: Option, + pub limit: Option, +} + +pub async fn get_audit_logs( + State(pool): State, + headers: HeaderMap, + Query(query): Query, +) -> Result>, (StatusCode, String)> { + // 1. Auth check + let auth_header = headers.get("Authorization") + .and_then(|h| h.to_str().ok()) + .ok_or((StatusCode::UNAUTHORIZED, "Missing Authorization header".into()))?; + + if !auth_header.starts_with("Bearer ") { + return Err((StatusCode::UNAUTHORIZED, "Invalid Authorization header".into())); + } + + let token = &auth_header[7..]; + let token_data = decode::( + token, + &DecodingKey::from_secret("secret".as_ref()), + &Validation::default(), + ).map_err(|e| { + tracing::error!("JWT decode failed: {}", e); + (StatusCode::UNAUTHORIZED, "Invalid token".into()) + })?; + + if token_data.claims.role != "admin" { + return Err((StatusCode::FORBIDDEN, "Only admins can view audit logs".into())); + } + + // 2. Query + let limit = query.limit.unwrap_or(50); + let offset = (query.page.unwrap_or(1) - 1) * limit; + + let logs = sqlx::query_as::<_, common::models::AuditLogResponse>( + r#" + SELECT a.id, a.user_id, u.full_name as user_full_name, a.action, a.entity_type, a.entity_id, a.changes, a.created_at + FROM audit_logs a + LEFT JOIN users u ON a.user_id = u.id + ORDER BY a.created_at DESC + LIMIT $1 OFFSET $2 + "# + ) + .bind(limit) + .bind(offset) + .fetch_all(&pool) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + + Ok(Json(logs)) +} diff --git a/services/cms-service/src/main.rs b/services/cms-service/src/main.rs index 1a2d15f..a9fe78b 100644 --- a/services/cms-service/src/main.rs +++ b/services/cms-service/src/main.rs @@ -47,6 +47,7 @@ async fn main() { .route("/grading", post(handlers::create_grading_category)) .route("/grading/{id}", delete(handlers::delete_grading_category)) .route("/courses/{id}/grading", get(handlers::get_grading_categories)) + .route("/audit-logs", get(handlers::get_audit_logs)) .route("/assets/upload", post(handlers::upload_asset)) .nest_service("/assets", tower_http::services::ServeDir::new("uploads")) .layer(cors) diff --git a/services/lms-service/migrations/20231223000001_add_certificate_template.sql b/services/lms-service/migrations/20231223000001_add_certificate_template.sql new file mode 100644 index 0000000..7205b28 --- /dev/null +++ b/services/lms-service/migrations/20231223000001_add_certificate_template.sql @@ -0,0 +1,2 @@ +-- Add certificate_template to courses +ALTER TABLE courses ADD COLUMN certificate_template TEXT; diff --git a/services/lms-service/src/handlers.rs b/services/lms-service/src/handlers.rs index e8ae5a4..c63ada2 100644 --- a/services/lms-service/src/handlers.rs +++ b/services/lms-service/src/handlers.rs @@ -130,8 +130,8 @@ pub async fn ingest_course( // 1. Upsert Course sqlx::query( - "INSERT INTO courses (id, title, description, instructor_id, start_date, end_date, passing_percentage, updated_at) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8) + "INSERT INTO courses (id, title, description, instructor_id, start_date, end_date, passing_percentage, certificate_template, updated_at) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) ON CONFLICT (id) DO UPDATE SET title = EXCLUDED.title, description = EXCLUDED.description, @@ -139,6 +139,7 @@ pub async fn ingest_course( start_date = EXCLUDED.start_date, end_date = EXCLUDED.end_date, passing_percentage = EXCLUDED.passing_percentage, + certificate_template = EXCLUDED.certificate_template, updated_at = EXCLUDED.updated_at" ) .bind(payload.course.id) @@ -148,6 +149,7 @@ pub async fn ingest_course( .bind(payload.course.start_date) .bind(payload.course.end_date) .bind(payload.course.passing_percentage) + .bind(&payload.course.certificate_template) .bind(payload.course.updated_at) .execute(&mut *tx) .await diff --git a/shared/common/src/models.rs b/shared/common/src/models.rs index e925108..7e48285 100644 --- a/shared/common/src/models.rs +++ b/shared/common/src/models.rs @@ -11,6 +11,7 @@ pub struct Course { pub start_date: Option>, pub end_date: Option>, pub passing_percentage: i32, + pub certificate_template: Option, pub created_at: DateTime, pub updated_at: DateTime, } @@ -74,6 +75,18 @@ pub struct AuditLog { pub created_at: DateTime, } +#[derive(Debug, Serialize, Deserialize, sqlx::FromRow)] +pub struct AuditLogResponse { + pub id: Uuid, + pub user_id: Uuid, + pub user_full_name: Option, + pub action: String, + pub entity_type: String, + pub entity_id: Uuid, + pub changes: serde_json::Value, + pub created_at: DateTime, +} + #[derive(Debug, Serialize, Deserialize, sqlx::FromRow)] pub struct Enrollment { pub id: Uuid, @@ -204,6 +217,7 @@ mod tests { start_date: None, end_date: None, passing_percentage: 70, + certificate_template: None, created_at: Utc::now(), updated_at: Utc::now(), }, diff --git a/web/experience/src/app/courses/[id]/progress/page.tsx b/web/experience/src/app/courses/[id]/progress/page.tsx index 5c6ec9a..0234799 100644 --- a/web/experience/src/app/courses/[id]/progress/page.tsx +++ b/web/experience/src/app/courses/[id]/progress/page.tsx @@ -214,18 +214,67 @@ export default function StudentProgressPage() { -
-
-
- + {/* Certificate Section */} + {totalWeightedGrade >= (course.passing_percentage || 70) ? ( +
+
+
+ +
+
+

Course Completed!

+

+ Congratulations! You have passed {course.title}. +

+
-
-

Certification Track

-

Maintain 60% or higher to earn your verified certificate.

+ +
+ ) : ( +
+
+
+ +
+
+

Certification Track

+

+ Maintain {course.passing_percentage || 70}% or higher to earn your verified certificate. +

+
-
- -
+
+ {Math.max(0, (course.passing_percentage || 70) - Math.round(totalWeightedGrade))}% to go +
+ + )} diff --git a/web/experience/src/lib/api.ts b/web/experience/src/lib/api.ts index 886bf22..99c54dd 100644 --- a/web/experience/src/lib/api.ts +++ b/web/experience/src/lib/api.ts @@ -6,6 +6,7 @@ export interface Course { description?: string; instructor_id: string; passing_percentage: number; + certificate_template?: string; created_at: string; } diff --git a/web/studio/src/app/admin/audit/page.tsx b/web/studio/src/app/admin/audit/page.tsx new file mode 100644 index 0000000..f7bef9e --- /dev/null +++ b/web/studio/src/app/admin/audit/page.tsx @@ -0,0 +1,137 @@ +"use client"; + +import React, { useEffect, useState } from 'react'; +import { useRouter } from 'next/navigation'; +import { cmsApi, AuditLog } from '@/lib/api'; +import { useAuth } from '@/context/AuthContext'; +import { ArrowLeft, Clock, ShieldAlert, X } from 'lucide-react'; + +export default function AuditLogsPage() { + const router = useRouter(); + const { user, loading: authLoading } = useAuth(); + const [logs, setLogs] = useState([]); + const [loading, setLoading] = useState(true); + const [selectedLog, setSelectedLog] = useState(null); + + useEffect(() => { + if (!authLoading) { + if (!user || user.role !== 'admin') { + router.push('/'); + return; + } + loadLogs(); + } + }, [user, authLoading, router]); + + const loadLogs = async () => { + try { + const data = await cmsApi.getAuditLogs(); + setLogs(data); + } catch (err) { + console.error("Failed to load audit logs", err); + } finally { + setLoading(false); + } + }; + + if (authLoading || loading) { + return ( +
+
+
+ ); + } + + return ( +
+ {/* Header */} +
+
+
+ +
+
+ +
+

System Audit Logs

+
+
+
+
+ +
+
+
+ + + + + + + + + + + + {logs.map((log) => ( + + + + + + + + ))} + +
UserActionEntityDateDetails
+ {log.user_full_name || 'System / Unknown'} +
{log.user_id.slice(0, 8)}...
+
+ + {log.action} + + +
{log.entity_type}
+
{log.entity_id}
+
+ + {new Date(log.created_at).toLocaleString()} + + +
+
+
+
+ + {/* Changes Detail Modal */} + {selectedLog && ( +
+
+
+

Change Details

+ +
+
+
+                                {JSON.stringify(selectedLog.changes, null, 2)}
+                            
+
+
+
+ )} +
+ ); +} diff --git a/web/studio/src/app/courses/[id]/analytics/page.tsx b/web/studio/src/app/courses/[id]/analytics/page.tsx index adb7a7e..ddad716 100644 --- a/web/studio/src/app/courses/[id]/analytics/page.tsx +++ b/web/studio/src/app/courses/[id]/analytics/page.tsx @@ -27,27 +27,20 @@ export default function AnalyticsPage() { const fetchData = async () => { // Wait for auth to load if (!user) { - console.log("AnalyticsPage: No user found yet."); return; } - console.log("AnalyticsPage: User found:", user); - console.log("AnalyticsPage: User Role:", user.role); - // Check authorization if (user.role !== 'admin' && user.role !== 'instructor') { - console.warn("AnalyticsPage: Unauthorized role. Redirecting to home.", user.role); router.push('/'); return; } try { - console.log("AnalyticsPage: Fetching data for course:", id); const [courseData, analyticsData] = await Promise.all([ cmsApi.getCourseWithFullOutline(id), cmsApi.getCourseAnalytics(id) ]); - console.log("AnalyticsPage: Data fetched successfully", { courseData, analyticsData }); setCourse(courseData); setAnalytics(analyticsData); } catch (err: unknown) { diff --git a/web/studio/src/app/courses/[id]/settings/page.tsx b/web/studio/src/app/courses/[id]/settings/page.tsx index 0463689..5da9994 100644 --- a/web/studio/src/app/courses/[id]/settings/page.tsx +++ b/web/studio/src/app/courses/[id]/settings/page.tsx @@ -3,13 +3,29 @@ import React, { useState, useEffect } from "react"; import { useParams, useRouter } from "next/navigation"; import { cmsApi, Course } from "@/lib/api"; -import { ArrowLeft, Save, Settings as SettingsIcon } from "lucide-react"; +import { ArrowLeft, Save, Settings as SettingsIcon, BookOpen } from "lucide-react"; + +const DEFAULT_CERTIFICATE_TEMPLATE = ` +
+
+ Certificate of Completion + This is to certify that + {{student_name}} + has successfully completed the course + {{course_title}} + with a score of {{score}}% + Dated + {{date}} +
+
+`; export default function CourseSettingsPage() { const { id } = useParams() as { id: string }; const router = useRouter(); const [course, setCourse] = useState(null); const [passingPercentage, setPassingPercentage] = useState(70); + const [certificateTemplate, setCertificateTemplate] = useState(""); const [loading, setLoading] = useState(true); const [saving, setSaving] = useState(false); @@ -19,6 +35,7 @@ export default function CourseSettingsPage() { const data = await cmsApi.getCourse(id); setCourse(data); setPassingPercentage(data.passing_percentage || 70); + setCertificateTemplate(data.certificate_template || DEFAULT_CERTIFICATE_TEMPLATE); } catch (err) { console.error("Failed to load course", err); } finally { @@ -31,7 +48,10 @@ export default function CourseSettingsPage() { const handleSave = async () => { setSaving(true); try { - const updated = await cmsApi.updateCourse(id, { passing_percentage: passingPercentage }); + const updated = await cmsApi.updateCourse(id, { + passing_percentage: passingPercentage, + certificate_template: certificateTemplate + }); setCourse(updated); alert("Course settings updated successfully!"); } catch (err) { @@ -142,6 +162,58 @@ export default function CourseSettingsPage() { + + {/* Certificate Template Section */} +
+
+
+ +
+

Certificate Template

+
+ +
+

+ Design the HTML certificate that students will receive upon passing the course. + Available variables: {"{{student_name}}"}, {"{{course_title}}"}, {"{{date}}"}, {"{{score}}"}. +

+ +
+
+ +