feat: implement audit logging and add certificate template field to courses
This commit is contained in:
@@ -0,0 +1,2 @@
|
||||
-- Add certificate_template to courses
|
||||
ALTER TABLE courses ADD COLUMN certificate_template TEXT;
|
||||
@@ -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<i64>,
|
||||
pub limit: Option<i64>,
|
||||
}
|
||||
|
||||
pub async fn get_audit_logs(
|
||||
State(pool): State<PgPool>,
|
||||
headers: HeaderMap,
|
||||
Query(query): Query<AuditQuery>,
|
||||
) -> Result<Json<Vec<common::models::AuditLogResponse>>, (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::<Claims>(
|
||||
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))
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
-- Add certificate_template to courses
|
||||
ALTER TABLE courses ADD COLUMN certificate_template TEXT;
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user