feat: Add LTI launch, lesson preview, course progress, bookmarks, and asset management features.
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,87 @@
|
||||
-- Migration to support course previews
|
||||
ALTER TABLE lessons ADD COLUMN is_previewable BOOLEAN NOT NULL DEFAULT FALSE;
|
||||
|
||||
-- Update Lesson Management Functions
|
||||
CREATE OR REPLACE FUNCTION fn_create_lesson(
|
||||
p_organization_id UUID,
|
||||
p_module_id UUID,
|
||||
p_title VARCHAR(255),
|
||||
p_content_type VARCHAR(50),
|
||||
p_content_url VARCHAR(500) DEFAULT NULL,
|
||||
p_position INTEGER DEFAULT 0,
|
||||
p_transcription JSONB DEFAULT NULL,
|
||||
p_metadata JSONB DEFAULT NULL,
|
||||
p_is_graded BOOLEAN DEFAULT FALSE,
|
||||
p_grading_category_id UUID DEFAULT NULL,
|
||||
p_max_attempts INTEGER DEFAULT NULL,
|
||||
p_allow_retry BOOLEAN DEFAULT TRUE,
|
||||
p_due_date TIMESTAMPTZ DEFAULT NULL,
|
||||
p_important_date_type VARCHAR(50) DEFAULT NULL,
|
||||
p_is_previewable BOOLEAN DEFAULT FALSE
|
||||
) RETURNS SETOF lessons AS $$
|
||||
BEGIN
|
||||
RETURN QUERY
|
||||
INSERT INTO lessons (
|
||||
organization_id, module_id, title, content_type, content_url,
|
||||
position, transcription, metadata, is_graded, grading_category_id,
|
||||
max_attempts, allow_retry, due_date, important_date_type, is_previewable
|
||||
)
|
||||
VALUES (
|
||||
p_organization_id, p_module_id, p_title, p_content_type, p_content_url,
|
||||
p_position, p_transcription, p_metadata, p_is_graded, p_grading_category_id,
|
||||
p_max_attempts, p_allow_retry, p_due_date, p_important_date_type, p_is_previewable
|
||||
)
|
||||
RETURNING *;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
CREATE OR REPLACE FUNCTION fn_update_lesson(
|
||||
p_id UUID,
|
||||
p_organization_id UUID,
|
||||
p_title VARCHAR(255) DEFAULT NULL,
|
||||
p_content_type VARCHAR(50) DEFAULT NULL,
|
||||
p_content_url VARCHAR(500) DEFAULT NULL,
|
||||
p_content_blocks JSONB DEFAULT NULL,
|
||||
p_transcription JSONB DEFAULT NULL,
|
||||
p_metadata JSONB DEFAULT NULL,
|
||||
p_is_graded BOOLEAN DEFAULT NULL,
|
||||
p_grading_category_id UUID DEFAULT NULL,
|
||||
p_max_attempts INTEGER DEFAULT NULL,
|
||||
p_allow_retry BOOLEAN DEFAULT NULL,
|
||||
p_position INTEGER DEFAULT NULL,
|
||||
p_due_date TIMESTAMPTZ DEFAULT NULL,
|
||||
p_important_date_type VARCHAR(50) DEFAULT NULL,
|
||||
p_summary TEXT DEFAULT NULL,
|
||||
p_is_previewable BOOLEAN DEFAULT NULL,
|
||||
p_clear_due_date BOOLEAN DEFAULT FALSE,
|
||||
p_clear_grading_category BOOLEAN DEFAULT FALSE
|
||||
) RETURNS SETOF lessons AS $$
|
||||
BEGIN
|
||||
RETURN QUERY
|
||||
UPDATE lessons
|
||||
SET title = COALESCE(p_title, title),
|
||||
content_type = COALESCE(p_content_type, content_type),
|
||||
content_url = COALESCE(p_content_url, content_url),
|
||||
content_blocks = COALESCE(p_content_blocks, content_blocks),
|
||||
transcription = COALESCE(p_transcription, transcription),
|
||||
metadata = COALESCE(p_metadata, metadata),
|
||||
is_graded = COALESCE(p_is_graded, is_graded),
|
||||
grading_category_id = CASE
|
||||
WHEN p_clear_grading_category THEN NULL
|
||||
ELSE COALESCE(p_grading_category_id, grading_category_id)
|
||||
END,
|
||||
max_attempts = COALESCE(p_max_attempts, max_attempts),
|
||||
allow_retry = COALESCE(p_allow_retry, allow_retry),
|
||||
position = COALESCE(p_position, position),
|
||||
due_date = CASE
|
||||
WHEN p_clear_due_date THEN NULL
|
||||
ELSE COALESCE(p_due_date, due_date)
|
||||
END,
|
||||
important_date_type = COALESCE(p_important_date_type, important_date_type),
|
||||
summary = COALESCE(p_summary, summary),
|
||||
is_previewable = COALESCE(p_is_previewable, is_previewable),
|
||||
updated_at = NOW()
|
||||
WHERE id = p_id AND organization_id = p_organization_id
|
||||
RETURNING *;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
@@ -0,0 +1,5 @@
|
||||
-- Migration: Add uploaded_by to assets table
|
||||
ALTER TABLE assets ADD COLUMN uploaded_by UUID REFERENCES users(id) ON DELETE SET NULL;
|
||||
|
||||
-- Index for performance when filtering by uploader
|
||||
CREATE INDEX idx_assets_uploaded_by ON assets(uploaded_by);
|
||||
@@ -12,7 +12,7 @@ use common::auth::{Claims, create_jwt, create_preview_token};
|
||||
use common::middleware::Org;
|
||||
use common::models::{
|
||||
AuthResponse, Course, CourseAnalytics, Lesson, Module, Organization, PublishedCourse,
|
||||
PublishedModule, User, UserResponse,
|
||||
PublishedModule, User, UserResponse, CourseInstructor,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::json;
|
||||
@@ -115,11 +115,23 @@ pub async fn publish_course(
|
||||
let mut course_for_pub = course.clone();
|
||||
course_for_pub.organization_id = target_org_id;
|
||||
|
||||
// 5. Fetch Course Team
|
||||
let instructors = sqlx::query_as::<_, CourseInstructor>(
|
||||
"SELECT ci.*, u.email, u.full_name FROM course_instructors ci
|
||||
JOIN users u ON ci.user_id = u.id
|
||||
WHERE ci.course_id = $1"
|
||||
)
|
||||
.bind(id)
|
||||
.fetch_all(&pool)
|
||||
.await
|
||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||
|
||||
let payload = PublishedCourse {
|
||||
course: course_for_pub,
|
||||
organization,
|
||||
grading_categories,
|
||||
modules: pub_modules,
|
||||
instructors: Some(instructors),
|
||||
dependencies: None,
|
||||
};
|
||||
|
||||
@@ -329,10 +341,10 @@ pub async fn update_course(
|
||||
.bind(org_ctx.id)
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.map_err(|_| (StatusCode::NOT_FOUND, "Course not found".into()))?;
|
||||
.map_err(|_| (StatusCode::NOT_FOUND, "Curso no encontrado".into()))?;
|
||||
|
||||
if claims.role != "admin" && existing.instructor_id != claims.sub {
|
||||
return Err((StatusCode::FORBIDDEN, "Not authorized".into()));
|
||||
return Err((StatusCode::FORBIDDEN, "No autorizado".into()));
|
||||
}
|
||||
|
||||
let title = payload
|
||||
@@ -547,6 +559,11 @@ pub async fn create_lesson(
|
||||
|
||||
let important_date_type = payload.get("important_date_type").and_then(|v| v.as_str());
|
||||
|
||||
let is_previewable = payload
|
||||
.get("is_previewable")
|
||||
.and_then(|v| v.as_bool())
|
||||
.unwrap_or(false);
|
||||
|
||||
let mut tx = pool
|
||||
.begin()
|
||||
.await
|
||||
@@ -575,7 +592,7 @@ pub async fn create_lesson(
|
||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||
|
||||
let lesson = sqlx::query_as::<_, Lesson>(
|
||||
"SELECT * FROM fn_create_lesson($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14)"
|
||||
"SELECT * FROM fn_create_lesson($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15)"
|
||||
)
|
||||
.bind(org_ctx.id)
|
||||
.bind(module_id)
|
||||
@@ -591,6 +608,7 @@ pub async fn create_lesson(
|
||||
.bind(allow_retry)
|
||||
.bind(due_date)
|
||||
.bind(important_date_type)
|
||||
.bind(is_previewable)
|
||||
.fetch_one(&mut *tx)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
@@ -1293,6 +1311,7 @@ pub async fn update_lesson(
|
||||
let metadata = payload.get("metadata").cloned();
|
||||
let important_date_type = payload.get("important_date_type").and_then(|v| v.as_str());
|
||||
let summary = payload.get("summary").and_then(|v| v.as_str());
|
||||
let is_previewable = payload.get("is_previewable").and_then(|v| v.as_bool());
|
||||
let content_blocks = payload.get("content_blocks").cloned();
|
||||
let transcription = payload.get("transcription").cloned();
|
||||
|
||||
@@ -1342,7 +1361,7 @@ pub async fn update_lesson(
|
||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||
|
||||
let lesson = sqlx::query_as::<_, Lesson>(
|
||||
"SELECT * FROM fn_update_lesson($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18)"
|
||||
"SELECT * FROM fn_update_lesson($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19)"
|
||||
)
|
||||
.bind(id)
|
||||
.bind(org_ctx.id)
|
||||
@@ -1360,6 +1379,7 @@ pub async fn update_lesson(
|
||||
.bind(due_date)
|
||||
.bind(important_date_type)
|
||||
.bind(summary)
|
||||
.bind(is_previewable)
|
||||
.bind(clear_due_date)
|
||||
.bind(clear_grading_category)
|
||||
.fetch_one(&mut *tx)
|
||||
@@ -1642,195 +1662,6 @@ pub async fn reorder_lessons(
|
||||
Ok(StatusCode::OK)
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct UploadResponse {
|
||||
pub id: Uuid,
|
||||
pub filename: String,
|
||||
pub url: String,
|
||||
}
|
||||
|
||||
pub async fn upload_asset(
|
||||
Org(org_ctx): Org,
|
||||
State(pool): State<PgPool>,
|
||||
mut multipart: axum::extract::Multipart,
|
||||
) -> Result<Json<UploadResponse>, (StatusCode, String)> {
|
||||
tracing::info!("Starting upload_asset for org: {}", org_ctx.id);
|
||||
let mut filename = String::new();
|
||||
let mut data = Vec::new();
|
||||
let mut mimetype = String::new();
|
||||
let mut course_id: Option<Uuid> = None;
|
||||
|
||||
while let Some(field) =
|
||||
multipart
|
||||
.next_field()
|
||||
.await
|
||||
.map_err(|e: axum::extract::multipart::MultipartError| {
|
||||
(StatusCode::BAD_REQUEST, e.to_string())
|
||||
})?
|
||||
{
|
||||
let name = field.name().unwrap_or_default().to_string();
|
||||
if name == "file" {
|
||||
filename = field.file_name().unwrap_or("unnamed").to_string();
|
||||
mimetype = field
|
||||
.content_type()
|
||||
.unwrap_or("application/octet-stream")
|
||||
.to_string();
|
||||
data = field
|
||||
.bytes()
|
||||
.await
|
||||
.map_err(|e: axum::extract::multipart::MultipartError| {
|
||||
(StatusCode::INTERNAL_SERVER_ERROR, e.to_string())
|
||||
})?
|
||||
.to_vec();
|
||||
} else if name == "course_id" {
|
||||
if let Ok(txt) = field.text().await {
|
||||
if let Ok(id) = Uuid::parse_str(&txt) {
|
||||
course_id = Some(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if data.is_empty() {
|
||||
return Err((StatusCode::BAD_REQUEST, "No file uploaded".to_string()));
|
||||
}
|
||||
|
||||
let asset_id = Uuid::new_v4();
|
||||
let extension = std::path::Path::new(&filename)
|
||||
.extension()
|
||||
.and_then(|s| s.to_str())
|
||||
.unwrap_or("");
|
||||
|
||||
let storage_filename = format!("{}.{}", asset_id, extension);
|
||||
let storage_path = format!("uploads/{}", storage_filename);
|
||||
|
||||
// Ensure uploads directory exists
|
||||
tokio::fs::create_dir_all("uploads")
|
||||
.await
|
||||
.map_err(|e: std::io::Error| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
|
||||
// Write file
|
||||
tokio::fs::write(&storage_path, data)
|
||||
.await
|
||||
.map_err(|e: std::io::Error| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
|
||||
// Record in DB
|
||||
let size_bytes = tokio::fs::metadata(&storage_path)
|
||||
.await
|
||||
.map(|m| m.len() as i64)
|
||||
.unwrap_or(0);
|
||||
|
||||
sqlx::query(
|
||||
"INSERT INTO assets (id, filename, storage_path, mimetype, size_bytes, organization_id, course_id) VALUES ($1, $2, $3, $4, $5, $6, $7)"
|
||||
)
|
||||
.bind(asset_id)
|
||||
.bind(&filename)
|
||||
.bind(storage_path)
|
||||
.bind(mimetype)
|
||||
.bind(size_bytes)
|
||||
.bind(org_ctx.id)
|
||||
.bind(course_id)
|
||||
.execute(&pool)
|
||||
.await
|
||||
.map_err(|e: sqlx::Error| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
|
||||
let url = format!("/assets/{}", storage_filename);
|
||||
|
||||
tracing::info!("Upload successful: {} -> {}", filename, url);
|
||||
Ok(Json(UploadResponse {
|
||||
id: asset_id,
|
||||
filename,
|
||||
url,
|
||||
}))
|
||||
}
|
||||
|
||||
pub async fn get_course_assets(
|
||||
Org(org_ctx): Org,
|
||||
State(pool): State<PgPool>,
|
||||
Path(course_id): Path<Uuid>,
|
||||
) -> Result<Json<Vec<common::models::Asset>>, StatusCode> {
|
||||
let assets = sqlx::query_as::<_, common::models::Asset>(
|
||||
"SELECT * FROM assets WHERE organization_id = $1 AND course_id = $2 ORDER BY created_at DESC"
|
||||
)
|
||||
.bind(org_ctx.id)
|
||||
.bind(course_id)
|
||||
.fetch_all(&pool)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
tracing::error!("Failed to fetch course assets: {}", e);
|
||||
StatusCode::INTERNAL_SERVER_ERROR
|
||||
})?;
|
||||
|
||||
Ok(Json(assets))
|
||||
}
|
||||
|
||||
pub async fn delete_asset(
|
||||
Org(org_ctx): Org,
|
||||
claims: Claims,
|
||||
State(pool): State<PgPool>,
|
||||
Path(asset_id): Path<Uuid>,
|
||||
) -> Result<StatusCode, (StatusCode, String)> {
|
||||
// 1. Fetch asset to verify ownership/org
|
||||
let asset = sqlx::query_as::<_, common::models::Asset>(
|
||||
"SELECT * FROM assets WHERE id = $1 AND organization_id = $2",
|
||||
)
|
||||
.bind(asset_id)
|
||||
.bind(org_ctx.id)
|
||||
.fetch_optional(&pool)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
|
||||
let asset = match asset {
|
||||
Some(a) => a,
|
||||
None => return Err((StatusCode::NOT_FOUND, "Asset not found".to_string())),
|
||||
};
|
||||
|
||||
// 2. Check permissions (only instructor of the course or admin)
|
||||
if claims.role != "admin" {
|
||||
// If linked to a course, check if user owns that course
|
||||
if let Some(cid) = asset.course_id {
|
||||
let course = sqlx::query_as::<_, Course>("SELECT * FROM courses WHERE id = $1")
|
||||
.bind(cid)
|
||||
.fetch_optional(&pool)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
|
||||
if let Some(c) = course {
|
||||
if c.instructor_id != claims.sub {
|
||||
return Err((
|
||||
StatusCode::FORBIDDEN,
|
||||
"Not authorized to delete this asset".to_string(),
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
// If not linked to a course, only admins might delete? Or maybe uploader?
|
||||
// For now, let's assume if it's orphaned, only admin deletes.
|
||||
if asset.course_id.is_none() {
|
||||
return Err((
|
||||
StatusCode::FORBIDDEN,
|
||||
"Only admins can delete global assets".to_string(),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Delete file
|
||||
// Note: storage_path is relative to working dir usually "uploads/..."
|
||||
if let Err(e) = tokio::fs::remove_file(&asset.storage_path).await {
|
||||
tracing::warn!("Failed to delete file {}: {}", asset.storage_path, e);
|
||||
// We continue to delete from DB even if file specific deletion failed (maybe already gone)
|
||||
}
|
||||
|
||||
// 4. Delete from DB
|
||||
sqlx::query("DELETE FROM assets WHERE id = $1")
|
||||
.bind(asset_id)
|
||||
.execute(&pool)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct AuthPayload {
|
||||
pub email: String,
|
||||
@@ -1988,7 +1819,7 @@ pub async fn login(
|
||||
"Verification failed".into(),
|
||||
)
|
||||
})? {
|
||||
return Err((StatusCode::UNAUTHORIZED, "Invalid credentials".into()));
|
||||
return Err((StatusCode::UNAUTHORIZED, "Credenciales inválidas".into()));
|
||||
}
|
||||
|
||||
let token = create_jwt(user.id, user.organization_id, &user.role).map_err(|_| {
|
||||
@@ -3059,7 +2890,7 @@ pub async fn export_course(
|
||||
})?;
|
||||
|
||||
if !exists {
|
||||
return Err((StatusCode::NOT_FOUND, "Course not found".to_string()));
|
||||
return Err((StatusCode::NOT_FOUND, "Rúbrica no encontrada".to_string()));
|
||||
}
|
||||
|
||||
// 2. Generate ZIP
|
||||
@@ -3143,8 +2974,8 @@ pub async fn import_course(
|
||||
let mimetype = mime_guess::from_path(&old_filename).first_or_octet_stream().to_string();
|
||||
|
||||
sqlx::query(
|
||||
"INSERT INTO assets (id, filename, storage_path, mimetype, size_bytes, organization_id)
|
||||
VALUES ($1, $2, $3, $4, $5, $6)"
|
||||
"INSERT INTO assets (id, filename, storage_path, mimetype, size_bytes, organization_id, uploaded_by)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7)"
|
||||
)
|
||||
.bind(new_id)
|
||||
.bind(&old_filename)
|
||||
@@ -3152,6 +2983,7 @@ pub async fn import_course(
|
||||
.bind(&mimetype)
|
||||
.bind(content.len() as i64)
|
||||
.bind(org_ctx.id)
|
||||
.bind(claims.sub)
|
||||
.execute(&pool)
|
||||
.await
|
||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||
@@ -3312,7 +3144,7 @@ pub async fn check_course_access(
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, sqlx::FromRow)]
|
||||
pub struct CourseInstructor {
|
||||
pub struct CourseInstructorDetail {
|
||||
pub id: Uuid,
|
||||
pub course_id: Uuid,
|
||||
pub user_id: Uuid,
|
||||
@@ -3326,18 +3158,21 @@ pub async fn get_course_team(
|
||||
Org(_org_ctx): Org,
|
||||
claims: Claims,
|
||||
State(pool): State<PgPool>,
|
||||
Path(id): Path<Uuid>,
|
||||
) -> Result<Json<Vec<CourseInstructor>>, (StatusCode, String)> {
|
||||
if !check_course_access(&pool, id, claims.sub, &claims.role).await? {
|
||||
Path(course_id): Path<Uuid>,
|
||||
) -> Result<Json<Vec<CourseInstructorDetail>>, (StatusCode, String)> {
|
||||
if !check_course_access(&pool, course_id, claims.sub, &claims.role).await? {
|
||||
return Err((StatusCode::FORBIDDEN, "No access to this course team".into()));
|
||||
}
|
||||
|
||||
let team = sqlx::query_as::<_, CourseInstructor>(
|
||||
"SELECT ci.*, u.email, u.full_name FROM course_instructors ci
|
||||
JOIN users u ON ci.user_id = u.id
|
||||
WHERE ci.course_id = $1"
|
||||
let team = sqlx::query_as::<_, CourseInstructorDetail>(
|
||||
r#"
|
||||
SELECT ci.id, ci.course_id, ci.user_id, ci.role, ci.created_at, u.email, u.full_name
|
||||
FROM course_instructors ci
|
||||
JOIN users u ON ci.user_id = u.id
|
||||
WHERE ci.course_id = $1
|
||||
"#
|
||||
)
|
||||
.bind(id)
|
||||
.bind(course_id)
|
||||
.fetch_all(&pool)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
|
||||
@@ -0,0 +1,206 @@
|
||||
use axum::{
|
||||
Json,
|
||||
extract::{Path, Query, State, Multipart},
|
||||
http::StatusCode,
|
||||
};
|
||||
use common::models::{Asset};
|
||||
use common::{auth::Claims, middleware::Org};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sqlx::PgPool;
|
||||
use uuid::Uuid;
|
||||
use std::path::Path as StdPath;
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct AssetUploadResponse {
|
||||
pub id: Uuid,
|
||||
pub filename: String,
|
||||
pub url: String,
|
||||
pub mimetype: String,
|
||||
pub size_bytes: i64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct AssetFilters {
|
||||
pub mimetype: Option<String>,
|
||||
pub course_id: Option<Uuid>,
|
||||
pub search: Option<String>,
|
||||
pub page: Option<u32>,
|
||||
pub limit: Option<u32>,
|
||||
}
|
||||
|
||||
/// POST /api/assets/upload - Subir un archivo a la biblioteca global
|
||||
pub async fn upload_asset(
|
||||
Org(org_ctx): Org,
|
||||
claims: Claims,
|
||||
State(pool): State<PgPool>,
|
||||
mut multipart: Multipart,
|
||||
) -> Result<Json<AssetUploadResponse>, (StatusCode, String)> {
|
||||
let mut filename = String::new();
|
||||
let mut data = Vec::new();
|
||||
let mut mimetype = String::new();
|
||||
let mut course_id: Option<Uuid> = None;
|
||||
|
||||
while let Some(field) = multipart
|
||||
.next_field()
|
||||
.await
|
||||
.map_err(|e| (StatusCode::BAD_REQUEST, e.to_string()))?
|
||||
{
|
||||
let name = field.name().unwrap_or_default().to_string();
|
||||
if name == "file" {
|
||||
filename = field.file_name().unwrap_or("unnamed").to_string();
|
||||
mimetype = field
|
||||
.content_type()
|
||||
.unwrap_or("application/octet-stream")
|
||||
.to_string();
|
||||
data = field
|
||||
.bytes()
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
|
||||
.to_vec();
|
||||
} else if name == "course_id" {
|
||||
if let Ok(txt) = field.text().await {
|
||||
if let Ok(id) = Uuid::parse_str(&txt) {
|
||||
course_id = Some(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if data.is_empty() {
|
||||
return Err((StatusCode::BAD_REQUEST, "No file uploaded".to_string()));
|
||||
}
|
||||
|
||||
let asset_id = Uuid::new_v4();
|
||||
let extension = StdPath::new(&filename)
|
||||
.extension()
|
||||
.and_then(|s| s.to_str())
|
||||
.unwrap_or("");
|
||||
|
||||
let storage_filename = format!("{}.{}", asset_id, extension);
|
||||
let storage_path = format!("uploads/{}", storage_filename);
|
||||
|
||||
// Ensure uploads directory exists
|
||||
tokio::fs::create_dir_all("uploads")
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
|
||||
// Write file
|
||||
tokio::fs::write(&storage_path, data)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
|
||||
let size_bytes = tokio::fs::metadata(&storage_path)
|
||||
.await
|
||||
.map(|m| m.len() as i64)
|
||||
.unwrap_or(0);
|
||||
|
||||
// Record in DB
|
||||
sqlx::query!(
|
||||
r#"
|
||||
INSERT INTO assets (id, organization_id, uploaded_by, course_id, filename, storage_path, mimetype, size_bytes)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
||||
"#,
|
||||
asset_id,
|
||||
org_ctx.id,
|
||||
claims.sub,
|
||||
course_id,
|
||||
filename,
|
||||
storage_path,
|
||||
mimetype,
|
||||
size_bytes
|
||||
)
|
||||
.execute(&pool)
|
||||
.await
|
||||
.map_err(|e: sqlx::Error| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
|
||||
Ok(Json(AssetUploadResponse {
|
||||
id: asset_id,
|
||||
filename,
|
||||
url: format!("/assets/{}", storage_filename),
|
||||
mimetype,
|
||||
size_bytes,
|
||||
}))
|
||||
}
|
||||
|
||||
/// GET /api/assets - Listar activos de la organización
|
||||
pub async fn list_assets(
|
||||
Org(org_ctx): Org,
|
||||
State(pool): State<PgPool>,
|
||||
Query(filters): Query<AssetFilters>,
|
||||
) -> Result<Json<Vec<Asset>>, (StatusCode, String)> {
|
||||
let limit = filters.limit.unwrap_or(50) as i64;
|
||||
let offset = ((filters.page.unwrap_or(1).max(1) - 1) * filters.limit.unwrap_or(50)) as i64;
|
||||
|
||||
let mut query = String::from("SELECT * FROM assets WHERE organization_id = $1");
|
||||
let mut param_index = 2;
|
||||
|
||||
if filters.mimetype.is_some() {
|
||||
query.push_str(&format!(" AND mimetype ILIKE ${}", param_index));
|
||||
param_index += 1;
|
||||
}
|
||||
|
||||
if filters.course_id.is_some() {
|
||||
query.push_str(&format!(" AND course_id = ${}", param_index));
|
||||
param_index += 1;
|
||||
}
|
||||
|
||||
if filters.search.is_some() {
|
||||
query.push_str(&format!(" AND filename ILIKE ${}", param_index));
|
||||
param_index += 1;
|
||||
}
|
||||
|
||||
query.push_str(&format!(" ORDER BY created_at DESC LIMIT ${} OFFSET ${}", param_index, param_index + 1));
|
||||
|
||||
let mut sql_query = sqlx::query_as::<_, Asset>(&query).bind(org_ctx.id);
|
||||
|
||||
if let Some(mt) = &filters.mimetype {
|
||||
sql_query = sql_query.bind(format!("%{}%", mt));
|
||||
}
|
||||
|
||||
if let Some(cid) = filters.course_id {
|
||||
sql_query = sql_query.bind(cid);
|
||||
}
|
||||
|
||||
if let Some(search) = &filters.search {
|
||||
sql_query = sql_query.bind(format!("%{}%", search));
|
||||
}
|
||||
|
||||
let assets = sql_query
|
||||
.bind(limit)
|
||||
.bind(offset)
|
||||
.fetch_all(&pool)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
|
||||
Ok(Json(assets))
|
||||
}
|
||||
|
||||
/// DELETE /api/assets/:id - Eliminar un activo y su archivo físico
|
||||
pub async fn delete_asset(
|
||||
Org(org_ctx): Org,
|
||||
State(pool): State<PgPool>,
|
||||
Path(id): Path<Uuid>,
|
||||
) -> Result<StatusCode, (StatusCode, String)> {
|
||||
// 1. Get asset metadata to find file path
|
||||
let asset = sqlx::query_as!(
|
||||
Asset,
|
||||
"SELECT * FROM assets WHERE id = $1 AND organization_id = $2",
|
||||
id,
|
||||
org_ctx.id
|
||||
)
|
||||
.fetch_optional(&pool)
|
||||
.await
|
||||
.map_err(|e: sqlx::Error| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
|
||||
.ok_or((StatusCode::NOT_FOUND, "Asset not found".to_string()))?;
|
||||
|
||||
// 2. Delete from DB
|
||||
sqlx::query!("DELETE FROM assets WHERE id = $1", id)
|
||||
.execute(&pool)
|
||||
.await
|
||||
.map_err(|e: sqlx::Error| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
|
||||
// 3. Delete physical file (async)
|
||||
let _ = tokio::fs::remove_file(&asset.storage_path).await;
|
||||
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
@@ -3,6 +3,7 @@ pub mod exporter;
|
||||
mod external_handlers;
|
||||
mod handlers;
|
||||
mod handlers_branding;
|
||||
mod handlers_assets;
|
||||
mod handlers_dependencies;
|
||||
mod handlers_library;
|
||||
mod handlers_rubrics;
|
||||
@@ -161,9 +162,9 @@ async fn main() {
|
||||
.route("/users/{id}", axum::routing::put(handlers::update_user))
|
||||
.route("/audit-logs", get(handlers::get_audit_logs))
|
||||
.route("/api/ai/review-text", post(handlers::review_text))
|
||||
.route("/api/assets/upload", post(handlers::upload_asset))
|
||||
.route("/api/assets/{id}", delete(handlers::delete_asset))
|
||||
.route("/courses/{id}/assets", get(handlers::get_course_assets))
|
||||
.route("/api/assets", get(handlers_assets::list_assets))
|
||||
.route("/api/assets/upload", post(handlers_assets::upload_asset))
|
||||
.route("/api/assets/{id}", delete(handlers_assets::delete_asset))
|
||||
.layer(DefaultBodyLimit::disable())
|
||||
.route(
|
||||
"/organizations",
|
||||
|
||||
@@ -20,3 +20,5 @@ tower-http.workspace = true
|
||||
bcrypt.workspace = true
|
||||
jsonwebtoken.workspace = true
|
||||
reqwest = { version = "0.12", features = ["json"] }
|
||||
urlencoding = "2.1"
|
||||
base64 = "0.22"
|
||||
|
||||
@@ -0,0 +1,620 @@
|
||||
Checking lms-service v0.1.0 (/home/juan/dev/openccb/services/lms-service)
|
||||
error: error communicating with database: Connection refused (os error 111)
|
||||
--> services/lms-service/src/handlers.rs:154:22
|
||||
|
|
||||
154 | let categories = sqlx::query!(
|
||||
| ______________________^
|
||||
155 | | "SELECT id, name FROM grading_categories WHERE course_id = $1 ORDER BY name",
|
||||
156 | | course_id
|
||||
157 | | )
|
||||
| |_____^
|
||||
|
|
||||
= note: this error originates in the macro `$crate::sqlx_macros::expand_query` which comes from the expansion of the macro `sqlx::query` (in Nightly builds, run with -Z macro-backtrace for more info)
|
||||
|
||||
error: error communicating with database: Connection refused (os error 111)
|
||||
--> services/lms-service/src/handlers.rs:163:20
|
||||
|
|
||||
163 | let students = sqlx::query!(
|
||||
| ____________________^
|
||||
164 | | r#"
|
||||
165 | | SELECT
|
||||
166 | | u.id,
|
||||
... |
|
||||
180 | | org_ctx.id
|
||||
181 | | )
|
||||
| |_____^
|
||||
|
|
||||
= note: this error originates in the macro `$crate::sqlx_macros::expand_query` which comes from the expansion of the macro `sqlx::query` (in Nightly builds, run with -Z macro-backtrace for more info)
|
||||
|
||||
error: error communicating with database: Connection refused (os error 111)
|
||||
--> services/lms-service/src/handlers.rs:193:27
|
||||
|
|
||||
193 | let detailed_grades = sqlx::query_as!(
|
||||
| ___________________________^
|
||||
194 | | UserCategoryGrade,
|
||||
195 | | r#"
|
||||
196 | | SELECT
|
||||
... |
|
||||
205 | | course_id
|
||||
206 | | )
|
||||
| |_____^
|
||||
|
|
||||
= note: this error originates in the macro `$crate::sqlx_macros::expand_query` which comes from the expansion of the macro `sqlx::query_as` (in Nightly builds, run with -Z macro-backtrace for more info)
|
||||
|
||||
error: error communicating with database: Connection refused (os error 111)
|
||||
--> services/lms-service/src/handlers.rs:896:24
|
||||
|
|
||||
896 | let dependencies = sqlx::query_as!(
|
||||
| ________________________^
|
||||
897 | | LessonDependency,
|
||||
898 | | r#"
|
||||
899 | | SELECT ld.*
|
||||
... |
|
||||
905 | | id
|
||||
906 | | )
|
||||
| |_____^
|
||||
|
|
||||
= note: this error originates in the macro `$crate::sqlx_macros::expand_query` which comes from the expansion of the macro `sqlx::query_as` (in Nightly builds, run with -Z macro-backtrace for more info)
|
||||
|
||||
error: error communicating with database: Connection refused (os error 111)
|
||||
--> services/lms-service/src/handlers.rs:1004:30
|
||||
|
|
||||
1004 | let unmet_dependencies = sqlx::query!(
|
||||
| ______________________________^
|
||||
1005 | | r#"
|
||||
1006 | | SELECT ld.prerequisite_lesson_id, p.title as prereq_title, ld.min_score_percentage
|
||||
1007 | | FROM lesson_dependencies ld
|
||||
... |
|
||||
1020 | | claims.sub
|
||||
1021 | | )
|
||||
| |_____^
|
||||
|
|
||||
= note: this error originates in the macro `$crate::sqlx_macros::expand_query` which comes from the expansion of the macro `sqlx::query` (in Nightly builds, run with -Z macro-backtrace for more info)
|
||||
|
||||
error: error communicating with database: Connection refused (os error 111)
|
||||
--> services/lms-service/src/handlers_announcements.rs:55:23
|
||||
|
|
||||
55 | let cohorts = sqlx::query!(
|
||||
| _______________________^
|
||||
56 | | "SELECT cohort_id FROM announcement_cohorts WHERE announcement_id = $1",
|
||||
57 | | a.id
|
||||
58 | | )
|
||||
| |_________^
|
||||
|
|
||||
= note: this error originates in the macro `$crate::sqlx_macros::expand_query` which comes from the expansion of the macro `sqlx::query` (in Nightly builds, run with -Z macro-backtrace for more info)
|
||||
|
||||
error: error communicating with database: Connection refused (os error 111)
|
||||
--> services/lms-service/src/handlers_peer_review.rs:21:46
|
||||
|
|
||||
21 | let existing: Option<CourseSubmission> = sqlx::query_as!(
|
||||
| ______________________________________________^
|
||||
22 | | CourseSubmission,
|
||||
23 | | "SELECT * FROM course_submissions WHERE user_id = $1 AND lesson_id = $2",
|
||||
24 | | claims.sub,
|
||||
25 | | lesson_id
|
||||
26 | | )
|
||||
| |_____^
|
||||
|
|
||||
= note: this error originates in the macro `$crate::sqlx_macros::expand_query` which comes from the expansion of the macro `sqlx::query_as` (in Nightly builds, run with -Z macro-backtrace for more info)
|
||||
|
||||
error: error communicating with database: Connection refused (os error 111)
|
||||
--> services/lms-service/src/handlers_peer_review.rs:33:23
|
||||
|
|
||||
33 | let updated = sqlx::query_as!(
|
||||
| _______________________^
|
||||
34 | | CourseSubmission,
|
||||
35 | | r#"
|
||||
36 | | UPDATE course_submissions
|
||||
... |
|
||||
43 | | lesson_id
|
||||
44 | | )
|
||||
| |_________^
|
||||
|
|
||||
= note: this error originates in the macro `$crate::sqlx_macros::expand_query` which comes from the expansion of the macro `sqlx::query_as` (in Nightly builds, run with -Z macro-backtrace for more info)
|
||||
|
||||
error: error communicating with database: Connection refused (os error 111)
|
||||
--> services/lms-service/src/handlers_peer_review.rs:53:22
|
||||
|
|
||||
53 | let submission = sqlx::query_as!(
|
||||
| ______________________^
|
||||
54 | | CourseSubmission,
|
||||
55 | | r#"
|
||||
56 | | INSERT INTO course_submissions (user_id, course_id, lesson_id, organization_id, content)
|
||||
... |
|
||||
64 | | payload.content
|
||||
65 | | )
|
||||
| |_____^
|
||||
|
|
||||
= note: this error originates in the macro `$crate::sqlx_macros::expand_query` which comes from the expansion of the macro `sqlx::query_as` (in Nightly builds, run with -Z macro-backtrace for more info)
|
||||
|
||||
error: error communicating with database: Connection refused (os error 111)
|
||||
--> services/lms-service/src/handlers_peer_review.rs:83:22
|
||||
|
|
||||
83 | let submission = sqlx::query_as!(
|
||||
| ______________________^
|
||||
84 | | CourseSubmission,
|
||||
85 | | r#"
|
||||
86 | | SELECT s.*
|
||||
... |
|
||||
105 | | org_ctx.id
|
||||
106 | | )
|
||||
| |_____^
|
||||
|
|
||||
= note: this error originates in the macro `$crate::sqlx_macros::expand_query` which comes from the expansion of the macro `sqlx::query_as` (in Nightly builds, run with -Z macro-backtrace for more info)
|
||||
|
||||
error: error communicating with database: Connection refused (os error 111)
|
||||
--> services/lms-service/src/handlers_peer_review.rs:122:22
|
||||
|
|
||||
122 | let submission = sqlx::query!(
|
||||
| ______________________^
|
||||
123 | | "SELECT user_id FROM course_submissions WHERE id = $1",
|
||||
124 | | payload.submission_id
|
||||
125 | | )
|
||||
| |_____^
|
||||
|
|
||||
= note: this error originates in the macro `$crate::sqlx_macros::expand_query` which comes from the expansion of the macro `sqlx::query` (in Nightly builds, run with -Z macro-backtrace for more info)
|
||||
|
||||
error: error communicating with database: Connection refused (os error 111)
|
||||
--> services/lms-service/src/handlers_peer_review.rs:143:20
|
||||
|
|
||||
143 | let existing = sqlx::query!(
|
||||
| ____________________^
|
||||
144 | | "SELECT id FROM peer_reviews WHERE submission_id = $1 AND reviewer_id = $2",
|
||||
145 | | payload.submission_id,
|
||||
146 | | claims.sub
|
||||
147 | | )
|
||||
| |_____^
|
||||
|
|
||||
= note: this error originates in the macro `$crate::sqlx_macros::expand_query` which comes from the expansion of the macro `sqlx::query` (in Nightly builds, run with -Z macro-backtrace for more info)
|
||||
|
||||
error: error communicating with database: Connection refused (os error 111)
|
||||
--> services/lms-service/src/handlers_peer_review.rs:160:18
|
||||
|
|
||||
160 | let review = sqlx::query_as!(
|
||||
| __________________^
|
||||
161 | | PeerReview,
|
||||
162 | | r#"
|
||||
163 | | INSERT INTO peer_reviews (submission_id, reviewer_id, score, feedback, organization_id)
|
||||
... |
|
||||
171 | | org_ctx.id
|
||||
172 | | )
|
||||
| |_____^
|
||||
|
|
||||
= note: this error originates in the macro `$crate::sqlx_macros::expand_query` which comes from the expansion of the macro `sqlx::query_as` (in Nightly builds, run with -Z macro-backtrace for more info)
|
||||
|
||||
error: error communicating with database: Connection refused (os error 111)
|
||||
--> services/lms-service/src/handlers_peer_review.rs:187:19
|
||||
|
|
||||
187 | let reviews = sqlx::query_as!(
|
||||
| ___________________^
|
||||
188 | | PeerReview,
|
||||
189 | | r#"
|
||||
190 | | SELECT pr.*
|
||||
... |
|
||||
196 | | lesson_id
|
||||
197 | | )
|
||||
| |_____^
|
||||
|
|
||||
= note: this error originates in the macro `$crate::sqlx_macros::expand_query` which comes from the expansion of the macro `sqlx::query_as` (in Nightly builds, run with -Z macro-backtrace for more info)
|
||||
|
||||
error[E0412]: cannot find type `AnalyticsFilter` in module `common::models`
|
||||
--> services/lms-service/src/handlers.rs:1736:42
|
||||
|
|
||||
1736 | Query(filter): Query<common::models::AnalyticsFilter>,
|
||||
| ^^^^^^^^^^^^^^^ not found in `common::models`
|
||||
|
||||
error[E0412]: cannot find type `RecommendationResponse` in this scope
|
||||
--> services/lms-service/src/handlers.rs:1802:18
|
||||
|
|
||||
1802 | ) -> Result<Json<RecommendationResponse>, (StatusCode, String)> {
|
||||
| ^^^^^^^^^^^^^^^^^^^^^^ not found in this scope
|
||||
|
|
||||
help: consider importing this struct
|
||||
|
|
||||
1 + use common::models::RecommendationResponse;
|
||||
|
|
||||
|
||||
error[E0412]: cannot find type `RecommendationResponse` in this scope
|
||||
--> services/lms-service/src/handlers.rs:1945:22
|
||||
|
|
||||
1945 | let ai_response: RecommendationResponse = response
|
||||
| ^^^^^^^^^^^^^^^^^^^^^^ not found in this scope
|
||||
|
|
||||
help: consider importing this struct
|
||||
|
|
||||
1 + use common::models::RecommendationResponse;
|
||||
|
|
||||
|
||||
error[E0425]: cannot find function `dangerous_insecure_decode` in crate `jsonwebtoken`
|
||||
--> services/lms-service/src/lti.rs:107:51
|
||||
|
|
||||
107 | let claims: serde_json::Value = jsonwebtoken::dangerous_insecure_decode(&payload.id_token)
|
||||
| ^^^^^^^^^^^^^^^^^^^^^^^^^ not found in `jsonwebtoken`
|
||||
|
||||
warning: unused imports: `SubmitAssignmentPayload` and `SubmitPeerReviewPayload`
|
||||
--> services/lms-service/src/handlers.rs:12:44
|
||||
|
|
||||
12 | Module, Notification, Organization, SubmitAssignmentPayload, SubmitPeerReviewPayload, User, UserResponse,
|
||||
| ^^^^^^^^^^^^^^^^^^^^^^^ ^^^^^^^^^^^^^^^^^^^^^^^
|
||||
|
|
||||
= note: `#[warn(unused_imports)]` (part of `#[warn(unused)]`) on by default
|
||||
|
||||
warning: unused import: `crate::lti`
|
||||
--> services/lms-service/src/handlers.rs:14:5
|
||||
|
|
||||
14 | use crate::lti;
|
||||
| ^^^^^^^^^^
|
||||
|
||||
error[E0282]: type annotations needed
|
||||
--> services/lms-service/src/handlers.rs:154:22
|
||||
|
|
||||
154 | let categories = sqlx::query!(
|
||||
| ______________________^
|
||||
155 | | "SELECT id, name FROM grading_categories WHERE course_id = $1 ORDER BY name",
|
||||
156 | | course_id
|
||||
... |
|
||||
159 | | .await
|
||||
| |__________^ cannot infer type
|
||||
|
||||
error[E0282]: type annotations needed
|
||||
--> services/lms-service/src/handlers.rs:160:15
|
||||
|
|
||||
160 | .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
| ^ - type must be known at this point
|
||||
|
|
||||
help: consider giving this closure parameter an explicit type
|
||||
|
|
||||
160 | .map_err(|e: /* Type */| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
| ++++++++++++
|
||||
|
||||
error[E0282]: type annotations needed
|
||||
--> services/lms-service/src/handlers.rs:163:20
|
||||
|
|
||||
163 | let students = sqlx::query!(
|
||||
| ____________________^
|
||||
164 | | r#"
|
||||
165 | | SELECT
|
||||
166 | | u.id,
|
||||
... |
|
||||
182 | | .fetch_all(&pool)
|
||||
183 | | .await
|
||||
| |__________^ cannot infer type
|
||||
|
||||
error[E0282]: type annotations needed
|
||||
--> services/lms-service/src/handlers.rs:184:15
|
||||
|
|
||||
184 | .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
| ^ - type must be known at this point
|
||||
|
|
||||
help: consider giving this closure parameter an explicit type
|
||||
|
|
||||
184 | .map_err(|e: /* Type */| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
| ++++++++++++
|
||||
|
||||
error[E0282]: type annotations needed
|
||||
--> services/lms-service/src/handlers.rs:193:27
|
||||
|
|
||||
193 | let detailed_grades = sqlx::query_as!(
|
||||
| ___________________________^
|
||||
194 | | UserCategoryGrade,
|
||||
195 | | r#"
|
||||
196 | | SELECT
|
||||
... |
|
||||
207 | | .fetch_all(&pool)
|
||||
208 | | .await
|
||||
| |__________^ cannot infer type
|
||||
|
||||
error[E0282]: type annotations needed
|
||||
--> services/lms-service/src/handlers.rs:209:15
|
||||
|
|
||||
209 | .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
| ^ - type must be known at this point
|
||||
|
|
||||
help: consider giving this closure parameter an explicit type
|
||||
|
|
||||
209 | .map_err(|e: /* Type */| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
| ++++++++++++
|
||||
|
||||
error[E0282]: type annotations needed
|
||||
--> services/lms-service/src/handlers.rs:896:24
|
||||
|
|
||||
896 | let dependencies = sqlx::query_as!(
|
||||
| ________________________^
|
||||
897 | | LessonDependency,
|
||||
898 | | r#"
|
||||
899 | | SELECT ld.*
|
||||
... |
|
||||
907 | | .fetch_all(&pool)
|
||||
908 | | .await
|
||||
| |__________^ cannot infer type
|
||||
|
||||
error[E0282]: type annotations needed
|
||||
--> services/lms-service/src/handlers.rs:1004:30
|
||||
|
|
||||
1004 | let unmet_dependencies = sqlx::query!(
|
||||
| ______________________________^
|
||||
1005 | | r#"
|
||||
1006 | | SELECT ld.prerequisite_lesson_id, p.title as prereq_title, ld.min_score_percentage
|
||||
1007 | | FROM lesson_dependencies ld
|
||||
... |
|
||||
1022 | | .fetch_all(&pool)
|
||||
1023 | | .await
|
||||
| |__________^ cannot infer type
|
||||
|
||||
error[E0277]: the trait bound `for<'r> DailyProgress: FromRow<'r, _>` is not satisfied
|
||||
--> services/lms-service/src/handlers.rs:1461:49
|
||||
|
|
||||
1461 | let daily_completions = sqlx::query_as::<_, common::models::DailyProgress>(
|
||||
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ the trait `for<'r> FromRow<'r, _>` is not implemented for `DailyProgress`
|
||||
|
|
||||
= help: the following other types implement trait `FromRow<'r, R>`:
|
||||
`()` implements `FromRow<'r, R>`
|
||||
`(T1, T2)` implements `FromRow<'r, R>`
|
||||
`(T1, T2, T3)` implements `FromRow<'r, R>`
|
||||
`(T1, T2, T3, T4)` implements `FromRow<'r, R>`
|
||||
`(T1, T2, T3, T4, T5)` implements `FromRow<'r, R>`
|
||||
`(T1, T2, T3, T4, T5, T6)` implements `FromRow<'r, R>`
|
||||
`(T1, T2, T3, T4, T5, T6, T7)` implements `FromRow<'r, R>`
|
||||
`(T1, T2, T3, T4, T5, T6, T7, T8)` implements `FromRow<'r, R>`
|
||||
and 58 others
|
||||
note: required by a bound in `sqlx::query_as`
|
||||
--> /home/juan/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/sqlx-core-0.8.6/src/query_as.rs:345:8
|
||||
|
|
||||
342 | pub fn query_as<'q, DB, O>(sql: &'q str) -> QueryAs<'q, DB, O, <DB as Database>::Arguments<'q>>
|
||||
| -------- required by a bound in this function
|
||||
...
|
||||
345 | O: for<'r> FromRow<'r, DB::Row>,
|
||||
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^ required by this bound in `query_as`
|
||||
|
||||
error[E0599]: the method `fetch_all` exists for struct `QueryAs<'_, Postgres, DailyProgress, PgArguments>`, but its trait bounds were not satisfied
|
||||
--> services/lms-service/src/handlers.rs:1476:6
|
||||
|
|
||||
1461 | let daily_completions = sqlx::query_as::<_, common::models::DailyProgress>(
|
||||
| _____________________________-
|
||||
1462 | | r#"
|
||||
1463 | | SELECT
|
||||
1464 | | TO_CHAR(created_at, 'YYYY-MM-DD') as date,
|
||||
... |
|
||||
1475 | | .bind(org_ctx.id)
|
||||
1476 | | .fetch_all(&pool)
|
||||
| | -^^^^^^^^^ method cannot be called on `QueryAs<'_, Postgres, DailyProgress, PgArguments>` due to unsatisfied trait bounds
|
||||
| |_____|
|
||||
|
|
||||
|
|
||||
::: /home/juan/dev/openccb/shared/common/src/models.rs:349:1
|
||||
|
|
||||
349 | pub struct DailyProgress {
|
||||
| ------------------------ doesn't satisfy `DailyProgress: FromRow<'r, PgRow>`
|
||||
|
|
||||
= note: the following trait bounds were not satisfied:
|
||||
`DailyProgress: FromRow<'r, PgRow>`
|
||||
|
||||
error[E0282]: type annotations needed
|
||||
--> services/lms-service/src/handlers.rs:1461:29
|
||||
|
|
||||
1461 | let daily_completions = sqlx::query_as::<_, common::models::DailyProgress>(
|
||||
| _____________________________^
|
||||
1462 | | r#"
|
||||
1463 | | SELECT
|
||||
1464 | | TO_CHAR(created_at, 'YYYY-MM-DD') as date,
|
||||
... |
|
||||
1476 | | .fetch_all(&pool)
|
||||
1477 | | .await
|
||||
| |__________^ cannot infer type
|
||||
|
||||
error[E0282]: type annotations needed
|
||||
--> services/lms-service/src/handlers_announcements.rs:55:23
|
||||
|
|
||||
55 | let cohorts = sqlx::query!(
|
||||
| _______________________^
|
||||
56 | | "SELECT cohort_id FROM announcement_cohorts WHERE announcement_id = $1",
|
||||
57 | | a.id
|
||||
... |
|
||||
60 | | .await
|
||||
| |______________^ cannot infer type
|
||||
|
||||
error[E0282]: type annotations needed
|
||||
--> services/lms-service/src/handlers_announcements.rs:61:19
|
||||
|
|
||||
61 | .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
| ^ - type must be known at this point
|
||||
|
|
||||
help: consider giving this closure parameter an explicit type
|
||||
|
|
||||
61 | .map_err(|e: /* Type */| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
| ++++++++++++
|
||||
|
||||
error[E0282]: type annotations needed
|
||||
--> services/lms-service/src/handlers_peer_review.rs:21:46
|
||||
|
|
||||
21 | let existing: Option<CourseSubmission> = sqlx::query_as!(
|
||||
| ______________________________________________^
|
||||
22 | | CourseSubmission,
|
||||
23 | | "SELECT * FROM course_submissions WHERE user_id = $1 AND lesson_id = $2",
|
||||
24 | | claims.sub,
|
||||
... |
|
||||
27 | | .fetch_optional(&pool)
|
||||
28 | | .await
|
||||
| |__________^ cannot infer type
|
||||
|
||||
error[E0282]: type annotations needed
|
||||
--> services/lms-service/src/handlers_peer_review.rs:29:15
|
||||
|
|
||||
29 | .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
| ^ - type must be known at this point
|
||||
|
|
||||
help: consider giving this closure parameter an explicit type
|
||||
|
|
||||
29 | .map_err(|e: /* Type */| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
| ++++++++++++
|
||||
|
||||
error[E0282]: type annotations needed
|
||||
--> services/lms-service/src/handlers_peer_review.rs:33:23
|
||||
|
|
||||
33 | let updated = sqlx::query_as!(
|
||||
| _______________________^
|
||||
34 | | CourseSubmission,
|
||||
35 | | r#"
|
||||
36 | | UPDATE course_submissions
|
||||
... |
|
||||
45 | | .fetch_one(&pool)
|
||||
46 | | .await
|
||||
| |______________^ cannot infer type
|
||||
|
||||
error[E0282]: type annotations needed
|
||||
--> services/lms-service/src/handlers_peer_review.rs:47:19
|
||||
|
|
||||
47 | .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
| ^ - type must be known at this point
|
||||
|
|
||||
help: consider giving this closure parameter an explicit type
|
||||
|
|
||||
47 | .map_err(|e: /* Type */| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
| ++++++++++++
|
||||
|
||||
error[E0282]: type annotations needed
|
||||
--> services/lms-service/src/handlers_peer_review.rs:53:22
|
||||
|
|
||||
53 | let submission = sqlx::query_as!(
|
||||
| ______________________^
|
||||
54 | | CourseSubmission,
|
||||
55 | | r#"
|
||||
56 | | INSERT INTO course_submissions (user_id, course_id, lesson_id, organization_id, content)
|
||||
... |
|
||||
66 | | .fetch_one(&pool)
|
||||
67 | | .await
|
||||
| |__________^ cannot infer type
|
||||
|
||||
error[E0282]: type annotations needed
|
||||
--> services/lms-service/src/handlers_peer_review.rs:68:15
|
||||
|
|
||||
68 | .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
| ^ - type must be known at this point
|
||||
|
|
||||
help: consider giving this closure parameter an explicit type
|
||||
|
|
||||
68 | .map_err(|e: /* Type */| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
| ++++++++++++
|
||||
|
||||
error[E0282]: type annotations needed
|
||||
--> services/lms-service/src/handlers_peer_review.rs:83:22
|
||||
|
|
||||
83 | let submission = sqlx::query_as!(
|
||||
| ______________________^
|
||||
84 | | CourseSubmission,
|
||||
85 | | r#"
|
||||
86 | | SELECT s.*
|
||||
... |
|
||||
107 | | .fetch_optional(&pool)
|
||||
108 | | .await
|
||||
| |__________^ cannot infer type
|
||||
|
||||
error[E0282]: type annotations needed
|
||||
--> services/lms-service/src/handlers_peer_review.rs:109:15
|
||||
|
|
||||
109 | .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
| ^ - type must be known at this point
|
||||
|
|
||||
help: consider giving this closure parameter an explicit type
|
||||
|
|
||||
109 | .map_err(|e: /* Type */| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
| ++++++++++++
|
||||
|
||||
error[E0282]: type annotations needed
|
||||
--> services/lms-service/src/handlers_peer_review.rs:122:22
|
||||
|
|
||||
122 | let submission = sqlx::query!(
|
||||
| ______________________^
|
||||
123 | | "SELECT user_id FROM course_submissions WHERE id = $1",
|
||||
124 | | payload.submission_id
|
||||
... |
|
||||
127 | | .await
|
||||
| |__________^ cannot infer type
|
||||
|
||||
error[E0282]: type annotations needed
|
||||
--> services/lms-service/src/handlers_peer_review.rs:128:15
|
||||
|
|
||||
128 | .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
| ^ - type must be known at this point
|
||||
|
|
||||
help: consider giving this closure parameter an explicit type
|
||||
|
|
||||
128 | .map_err(|e: /* Type */| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
| ++++++++++++
|
||||
|
||||
error[E0282]: type annotations needed
|
||||
--> services/lms-service/src/handlers_peer_review.rs:143:20
|
||||
|
|
||||
143 | let existing = sqlx::query!(
|
||||
| ____________________^
|
||||
144 | | "SELECT id FROM peer_reviews WHERE submission_id = $1 AND reviewer_id = $2",
|
||||
145 | | payload.submission_id,
|
||||
146 | | claims.sub
|
||||
147 | | )
|
||||
148 | | .fetch_optional(&pool)
|
||||
149 | | .await
|
||||
| |__________^ cannot infer type
|
||||
|
||||
error[E0282]: type annotations needed
|
||||
--> services/lms-service/src/handlers_peer_review.rs:150:15
|
||||
|
|
||||
150 | .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
| ^ - type must be known at this point
|
||||
|
|
||||
help: consider giving this closure parameter an explicit type
|
||||
|
|
||||
150 | .map_err(|e: /* Type */| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
| ++++++++++++
|
||||
|
||||
error[E0282]: type annotations needed
|
||||
--> services/lms-service/src/handlers_peer_review.rs:160:18
|
||||
|
|
||||
160 | let review = sqlx::query_as!(
|
||||
| __________________^
|
||||
161 | | PeerReview,
|
||||
162 | | r#"
|
||||
163 | | INSERT INTO peer_reviews (submission_id, reviewer_id, score, feedback, organization_id)
|
||||
... |
|
||||
173 | | .fetch_one(&pool)
|
||||
174 | | .await
|
||||
| |__________^ cannot infer type
|
||||
|
||||
error[E0282]: type annotations needed
|
||||
--> services/lms-service/src/handlers_peer_review.rs:175:15
|
||||
|
|
||||
175 | .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
| ^ - type must be known at this point
|
||||
|
|
||||
help: consider giving this closure parameter an explicit type
|
||||
|
|
||||
175 | .map_err(|e: /* Type */| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
| ++++++++++++
|
||||
|
||||
error[E0282]: type annotations needed
|
||||
--> services/lms-service/src/handlers_peer_review.rs:187:19
|
||||
|
|
||||
187 | let reviews = sqlx::query_as!(
|
||||
| ___________________^
|
||||
188 | | PeerReview,
|
||||
189 | | r#"
|
||||
190 | | SELECT pr.*
|
||||
... |
|
||||
198 | | .fetch_all(&pool)
|
||||
199 | | .await
|
||||
| |__________^ cannot infer type
|
||||
|
||||
error[E0282]: type annotations needed
|
||||
--> services/lms-service/src/handlers_peer_review.rs:200:15
|
||||
|
|
||||
200 | .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
| ^ - type must be known at this point
|
||||
|
|
||||
help: consider giving this closure parameter an explicit type
|
||||
|
|
||||
200 | .map_err(|e: /* Type */| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
| ++++++++++++
|
||||
|
||||
Some errors have detailed explanations: E0277, E0282, E0412, E0425, E0599.
|
||||
For more information about an error, try `rustc --explain E0277`.
|
||||
warning: `lms-service` (bin "lms-service") generated 2 warnings
|
||||
error: could not compile `lms-service` (bin "lms-service") due to 47 previous errors; 2 warnings emitted
|
||||
@@ -0,0 +1,518 @@
|
||||
Checking lms-service v0.1.0 (/home/juan/dev/openccb/services/lms-service)
|
||||
error: error communicating with database: Connection refused (os error 111)
|
||||
--> services/lms-service/src/handlers.rs:154:22
|
||||
|
|
||||
154 | let categories = sqlx::query!(
|
||||
| ______________________^
|
||||
155 | | "SELECT id, name FROM grading_categories WHERE course_id = $1 ORDER BY name",
|
||||
156 | | course_id
|
||||
157 | | )
|
||||
| |_____^
|
||||
|
|
||||
= note: this error originates in the macro `$crate::sqlx_macros::expand_query` which comes from the expansion of the macro `sqlx::query` (in Nightly builds, run with -Z macro-backtrace for more info)
|
||||
|
||||
error: error communicating with database: Connection refused (os error 111)
|
||||
--> services/lms-service/src/handlers.rs:163:20
|
||||
|
|
||||
163 | let students = sqlx::query!(
|
||||
| ____________________^
|
||||
164 | | r#"
|
||||
165 | | SELECT
|
||||
166 | | u.id,
|
||||
... |
|
||||
180 | | org_ctx.id
|
||||
181 | | )
|
||||
| |_____^
|
||||
|
|
||||
= note: this error originates in the macro `$crate::sqlx_macros::expand_query` which comes from the expansion of the macro `sqlx::query` (in Nightly builds, run with -Z macro-backtrace for more info)
|
||||
|
||||
error: error communicating with database: Connection refused (os error 111)
|
||||
--> services/lms-service/src/handlers.rs:193:27
|
||||
|
|
||||
193 | let detailed_grades = sqlx::query_as!(
|
||||
| ___________________________^
|
||||
194 | | UserCategoryGrade,
|
||||
195 | | r#"
|
||||
196 | | SELECT
|
||||
... |
|
||||
205 | | course_id
|
||||
206 | | )
|
||||
| |_____^
|
||||
|
|
||||
= note: this error originates in the macro `$crate::sqlx_macros::expand_query` which comes from the expansion of the macro `sqlx::query_as` (in Nightly builds, run with -Z macro-backtrace for more info)
|
||||
|
||||
error: error communicating with database: Connection refused (os error 111)
|
||||
--> services/lms-service/src/handlers.rs:896:24
|
||||
|
|
||||
896 | let dependencies = sqlx::query_as!(
|
||||
| ________________________^
|
||||
897 | | LessonDependency,
|
||||
898 | | r#"
|
||||
899 | | SELECT ld.*
|
||||
... |
|
||||
905 | | id
|
||||
906 | | )
|
||||
| |_____^
|
||||
|
|
||||
= note: this error originates in the macro `$crate::sqlx_macros::expand_query` which comes from the expansion of the macro `sqlx::query_as` (in Nightly builds, run with -Z macro-backtrace for more info)
|
||||
|
||||
error: error communicating with database: Connection refused (os error 111)
|
||||
--> services/lms-service/src/handlers.rs:1004:30
|
||||
|
|
||||
1004 | let unmet_dependencies = sqlx::query!(
|
||||
| ______________________________^
|
||||
1005 | | r#"
|
||||
1006 | | SELECT ld.prerequisite_lesson_id, p.title as prereq_title, ld.min_score_percentage
|
||||
1007 | | FROM lesson_dependencies ld
|
||||
... |
|
||||
1020 | | claims.sub
|
||||
1021 | | )
|
||||
| |_____^
|
||||
|
|
||||
= note: this error originates in the macro `$crate::sqlx_macros::expand_query` which comes from the expansion of the macro `sqlx::query` (in Nightly builds, run with -Z macro-backtrace for more info)
|
||||
|
||||
error: error communicating with database: Connection refused (os error 111)
|
||||
--> services/lms-service/src/handlers_announcements.rs:55:23
|
||||
|
|
||||
55 | let cohorts = sqlx::query!(
|
||||
| _______________________^
|
||||
56 | | "SELECT cohort_id FROM announcement_cohorts WHERE announcement_id = $1",
|
||||
57 | | a.id
|
||||
58 | | )
|
||||
| |_________^
|
||||
|
|
||||
= note: this error originates in the macro `$crate::sqlx_macros::expand_query` which comes from the expansion of the macro `sqlx::query` (in Nightly builds, run with -Z macro-backtrace for more info)
|
||||
|
||||
error: error communicating with database: Connection refused (os error 111)
|
||||
--> services/lms-service/src/handlers_peer_review.rs:21:46
|
||||
|
|
||||
21 | let existing: Option<CourseSubmission> = sqlx::query_as!(
|
||||
| ______________________________________________^
|
||||
22 | | CourseSubmission,
|
||||
23 | | "SELECT * FROM course_submissions WHERE user_id = $1 AND lesson_id = $2",
|
||||
24 | | claims.sub,
|
||||
25 | | lesson_id
|
||||
26 | | )
|
||||
| |_____^
|
||||
|
|
||||
= note: this error originates in the macro `$crate::sqlx_macros::expand_query` which comes from the expansion of the macro `sqlx::query_as` (in Nightly builds, run with -Z macro-backtrace for more info)
|
||||
|
||||
error: error communicating with database: Connection refused (os error 111)
|
||||
--> services/lms-service/src/handlers_peer_review.rs:33:23
|
||||
|
|
||||
33 | let updated = sqlx::query_as!(
|
||||
| _______________________^
|
||||
34 | | CourseSubmission,
|
||||
35 | | r#"
|
||||
36 | | UPDATE course_submissions
|
||||
... |
|
||||
43 | | lesson_id
|
||||
44 | | )
|
||||
| |_________^
|
||||
|
|
||||
= note: this error originates in the macro `$crate::sqlx_macros::expand_query` which comes from the expansion of the macro `sqlx::query_as` (in Nightly builds, run with -Z macro-backtrace for more info)
|
||||
|
||||
error: error communicating with database: Connection refused (os error 111)
|
||||
--> services/lms-service/src/handlers_peer_review.rs:53:22
|
||||
|
|
||||
53 | let submission = sqlx::query_as!(
|
||||
| ______________________^
|
||||
54 | | CourseSubmission,
|
||||
55 | | r#"
|
||||
56 | | INSERT INTO course_submissions (user_id, course_id, lesson_id, organization_id, content)
|
||||
... |
|
||||
64 | | payload.content
|
||||
65 | | )
|
||||
| |_____^
|
||||
|
|
||||
= note: this error originates in the macro `$crate::sqlx_macros::expand_query` which comes from the expansion of the macro `sqlx::query_as` (in Nightly builds, run with -Z macro-backtrace for more info)
|
||||
|
||||
error: error communicating with database: Connection refused (os error 111)
|
||||
--> services/lms-service/src/handlers_peer_review.rs:83:22
|
||||
|
|
||||
83 | let submission = sqlx::query_as!(
|
||||
| ______________________^
|
||||
84 | | CourseSubmission,
|
||||
85 | | r#"
|
||||
86 | | SELECT s.*
|
||||
... |
|
||||
105 | | org_ctx.id
|
||||
106 | | )
|
||||
| |_____^
|
||||
|
|
||||
= note: this error originates in the macro `$crate::sqlx_macros::expand_query` which comes from the expansion of the macro `sqlx::query_as` (in Nightly builds, run with -Z macro-backtrace for more info)
|
||||
|
||||
error: error communicating with database: Connection refused (os error 111)
|
||||
--> services/lms-service/src/handlers_peer_review.rs:122:22
|
||||
|
|
||||
122 | let submission = sqlx::query!(
|
||||
| ______________________^
|
||||
123 | | "SELECT user_id FROM course_submissions WHERE id = $1",
|
||||
124 | | payload.submission_id
|
||||
125 | | )
|
||||
| |_____^
|
||||
|
|
||||
= note: this error originates in the macro `$crate::sqlx_macros::expand_query` which comes from the expansion of the macro `sqlx::query` (in Nightly builds, run with -Z macro-backtrace for more info)
|
||||
|
||||
error: error communicating with database: Connection refused (os error 111)
|
||||
--> services/lms-service/src/handlers_peer_review.rs:143:20
|
||||
|
|
||||
143 | let existing = sqlx::query!(
|
||||
| ____________________^
|
||||
144 | | "SELECT id FROM peer_reviews WHERE submission_id = $1 AND reviewer_id = $2",
|
||||
145 | | payload.submission_id,
|
||||
146 | | claims.sub
|
||||
147 | | )
|
||||
| |_____^
|
||||
|
|
||||
= note: this error originates in the macro `$crate::sqlx_macros::expand_query` which comes from the expansion of the macro `sqlx::query` (in Nightly builds, run with -Z macro-backtrace for more info)
|
||||
|
||||
error: error communicating with database: Connection refused (os error 111)
|
||||
--> services/lms-service/src/handlers_peer_review.rs:160:18
|
||||
|
|
||||
160 | let review = sqlx::query_as!(
|
||||
| __________________^
|
||||
161 | | PeerReview,
|
||||
162 | | r#"
|
||||
163 | | INSERT INTO peer_reviews (submission_id, reviewer_id, score, feedback, organization_id)
|
||||
... |
|
||||
171 | | org_ctx.id
|
||||
172 | | )
|
||||
| |_____^
|
||||
|
|
||||
= note: this error originates in the macro `$crate::sqlx_macros::expand_query` which comes from the expansion of the macro `sqlx::query_as` (in Nightly builds, run with -Z macro-backtrace for more info)
|
||||
|
||||
error: error communicating with database: Connection refused (os error 111)
|
||||
--> services/lms-service/src/handlers_peer_review.rs:187:19
|
||||
|
|
||||
187 | let reviews = sqlx::query_as!(
|
||||
| ___________________^
|
||||
188 | | PeerReview,
|
||||
189 | | r#"
|
||||
190 | | SELECT pr.*
|
||||
... |
|
||||
196 | | lesson_id
|
||||
197 | | )
|
||||
| |_____^
|
||||
|
|
||||
= note: this error originates in the macro `$crate::sqlx_macros::expand_query` which comes from the expansion of the macro `sqlx::query_as` (in Nightly builds, run with -Z macro-backtrace for more info)
|
||||
|
||||
warning: unused import: `crate::lti`
|
||||
--> services/lms-service/src/handlers.rs:14:5
|
||||
|
|
||||
14 | use crate::lti;
|
||||
| ^^^^^^^^^^
|
||||
|
|
||||
= note: `#[warn(unused_imports)]` (part of `#[warn(unused)]`) on by default
|
||||
|
||||
error[E0282]: type annotations needed
|
||||
--> services/lms-service/src/handlers.rs:154:22
|
||||
|
|
||||
154 | let categories = sqlx::query!(
|
||||
| ______________________^
|
||||
155 | | "SELECT id, name FROM grading_categories WHERE course_id = $1 ORDER BY name",
|
||||
156 | | course_id
|
||||
... |
|
||||
159 | | .await
|
||||
| |__________^ cannot infer type
|
||||
|
||||
error[E0282]: type annotations needed
|
||||
--> services/lms-service/src/handlers.rs:160:15
|
||||
|
|
||||
160 | .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
| ^ - type must be known at this point
|
||||
|
|
||||
help: consider giving this closure parameter an explicit type
|
||||
|
|
||||
160 | .map_err(|e: /* Type */| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
| ++++++++++++
|
||||
|
||||
error[E0282]: type annotations needed
|
||||
--> services/lms-service/src/handlers.rs:163:20
|
||||
|
|
||||
163 | let students = sqlx::query!(
|
||||
| ____________________^
|
||||
164 | | r#"
|
||||
165 | | SELECT
|
||||
166 | | u.id,
|
||||
... |
|
||||
182 | | .fetch_all(&pool)
|
||||
183 | | .await
|
||||
| |__________^ cannot infer type
|
||||
|
||||
error[E0282]: type annotations needed
|
||||
--> services/lms-service/src/handlers.rs:184:15
|
||||
|
|
||||
184 | .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
| ^ - type must be known at this point
|
||||
|
|
||||
help: consider giving this closure parameter an explicit type
|
||||
|
|
||||
184 | .map_err(|e: /* Type */| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
| ++++++++++++
|
||||
|
||||
error[E0282]: type annotations needed
|
||||
--> services/lms-service/src/handlers.rs:193:27
|
||||
|
|
||||
193 | let detailed_grades = sqlx::query_as!(
|
||||
| ___________________________^
|
||||
194 | | UserCategoryGrade,
|
||||
195 | | r#"
|
||||
196 | | SELECT
|
||||
... |
|
||||
207 | | .fetch_all(&pool)
|
||||
208 | | .await
|
||||
| |__________^ cannot infer type
|
||||
|
||||
error[E0282]: type annotations needed
|
||||
--> services/lms-service/src/handlers.rs:209:15
|
||||
|
|
||||
209 | .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
| ^ - type must be known at this point
|
||||
|
|
||||
help: consider giving this closure parameter an explicit type
|
||||
|
|
||||
209 | .map_err(|e: /* Type */| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
| ++++++++++++
|
||||
|
||||
error[E0282]: type annotations needed
|
||||
--> services/lms-service/src/handlers.rs:896:24
|
||||
|
|
||||
896 | let dependencies = sqlx::query_as!(
|
||||
| ________________________^
|
||||
897 | | LessonDependency,
|
||||
898 | | r#"
|
||||
899 | | SELECT ld.*
|
||||
... |
|
||||
907 | | .fetch_all(&pool)
|
||||
908 | | .await
|
||||
| |__________^ cannot infer type
|
||||
|
||||
error[E0282]: type annotations needed
|
||||
--> services/lms-service/src/handlers.rs:1004:30
|
||||
|
|
||||
1004 | let unmet_dependencies = sqlx::query!(
|
||||
| ______________________________^
|
||||
1005 | | r#"
|
||||
1006 | | SELECT ld.prerequisite_lesson_id, p.title as prereq_title, ld.min_score_percentage
|
||||
1007 | | FROM lesson_dependencies ld
|
||||
... |
|
||||
1022 | | .fetch_all(&pool)
|
||||
1023 | | .await
|
||||
| |__________^ cannot infer type
|
||||
|
||||
error[E0282]: type annotations needed
|
||||
--> services/lms-service/src/handlers_announcements.rs:55:23
|
||||
|
|
||||
55 | let cohorts = sqlx::query!(
|
||||
| _______________________^
|
||||
56 | | "SELECT cohort_id FROM announcement_cohorts WHERE announcement_id = $1",
|
||||
57 | | a.id
|
||||
... |
|
||||
60 | | .await
|
||||
| |______________^ cannot infer type
|
||||
|
||||
error[E0282]: type annotations needed
|
||||
--> services/lms-service/src/handlers_announcements.rs:61:19
|
||||
|
|
||||
61 | .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
| ^ - type must be known at this point
|
||||
|
|
||||
help: consider giving this closure parameter an explicit type
|
||||
|
|
||||
61 | .map_err(|e: /* Type */| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
| ++++++++++++
|
||||
|
||||
error[E0282]: type annotations needed
|
||||
--> services/lms-service/src/handlers_peer_review.rs:21:46
|
||||
|
|
||||
21 | let existing: Option<CourseSubmission> = sqlx::query_as!(
|
||||
| ______________________________________________^
|
||||
22 | | CourseSubmission,
|
||||
23 | | "SELECT * FROM course_submissions WHERE user_id = $1 AND lesson_id = $2",
|
||||
24 | | claims.sub,
|
||||
... |
|
||||
27 | | .fetch_optional(&pool)
|
||||
28 | | .await
|
||||
| |__________^ cannot infer type
|
||||
|
||||
error[E0282]: type annotations needed
|
||||
--> services/lms-service/src/handlers_peer_review.rs:29:15
|
||||
|
|
||||
29 | .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
| ^ - type must be known at this point
|
||||
|
|
||||
help: consider giving this closure parameter an explicit type
|
||||
|
|
||||
29 | .map_err(|e: /* Type */| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
| ++++++++++++
|
||||
|
||||
error[E0282]: type annotations needed
|
||||
--> services/lms-service/src/handlers_peer_review.rs:33:23
|
||||
|
|
||||
33 | let updated = sqlx::query_as!(
|
||||
| _______________________^
|
||||
34 | | CourseSubmission,
|
||||
35 | | r#"
|
||||
36 | | UPDATE course_submissions
|
||||
... |
|
||||
45 | | .fetch_one(&pool)
|
||||
46 | | .await
|
||||
| |______________^ cannot infer type
|
||||
|
||||
error[E0282]: type annotations needed
|
||||
--> services/lms-service/src/handlers_peer_review.rs:47:19
|
||||
|
|
||||
47 | .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
| ^ - type must be known at this point
|
||||
|
|
||||
help: consider giving this closure parameter an explicit type
|
||||
|
|
||||
47 | .map_err(|e: /* Type */| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
| ++++++++++++
|
||||
|
||||
error[E0282]: type annotations needed
|
||||
--> services/lms-service/src/handlers_peer_review.rs:53:22
|
||||
|
|
||||
53 | let submission = sqlx::query_as!(
|
||||
| ______________________^
|
||||
54 | | CourseSubmission,
|
||||
55 | | r#"
|
||||
56 | | INSERT INTO course_submissions (user_id, course_id, lesson_id, organization_id, content)
|
||||
... |
|
||||
66 | | .fetch_one(&pool)
|
||||
67 | | .await
|
||||
| |__________^ cannot infer type
|
||||
|
||||
error[E0282]: type annotations needed
|
||||
--> services/lms-service/src/handlers_peer_review.rs:68:15
|
||||
|
|
||||
68 | .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
| ^ - type must be known at this point
|
||||
|
|
||||
help: consider giving this closure parameter an explicit type
|
||||
|
|
||||
68 | .map_err(|e: /* Type */| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
| ++++++++++++
|
||||
|
||||
error[E0282]: type annotations needed
|
||||
--> services/lms-service/src/handlers_peer_review.rs:83:22
|
||||
|
|
||||
83 | let submission = sqlx::query_as!(
|
||||
| ______________________^
|
||||
84 | | CourseSubmission,
|
||||
85 | | r#"
|
||||
86 | | SELECT s.*
|
||||
... |
|
||||
107 | | .fetch_optional(&pool)
|
||||
108 | | .await
|
||||
| |__________^ cannot infer type
|
||||
|
||||
error[E0282]: type annotations needed
|
||||
--> services/lms-service/src/handlers_peer_review.rs:109:15
|
||||
|
|
||||
109 | .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
| ^ - type must be known at this point
|
||||
|
|
||||
help: consider giving this closure parameter an explicit type
|
||||
|
|
||||
109 | .map_err(|e: /* Type */| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
| ++++++++++++
|
||||
|
||||
error[E0282]: type annotations needed
|
||||
--> services/lms-service/src/handlers_peer_review.rs:122:22
|
||||
|
|
||||
122 | let submission = sqlx::query!(
|
||||
| ______________________^
|
||||
123 | | "SELECT user_id FROM course_submissions WHERE id = $1",
|
||||
124 | | payload.submission_id
|
||||
... |
|
||||
127 | | .await
|
||||
| |__________^ cannot infer type
|
||||
|
||||
error[E0282]: type annotations needed
|
||||
--> services/lms-service/src/handlers_peer_review.rs:128:15
|
||||
|
|
||||
128 | .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
| ^ - type must be known at this point
|
||||
|
|
||||
help: consider giving this closure parameter an explicit type
|
||||
|
|
||||
128 | .map_err(|e: /* Type */| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
| ++++++++++++
|
||||
|
||||
error[E0282]: type annotations needed
|
||||
--> services/lms-service/src/handlers_peer_review.rs:143:20
|
||||
|
|
||||
143 | let existing = sqlx::query!(
|
||||
| ____________________^
|
||||
144 | | "SELECT id FROM peer_reviews WHERE submission_id = $1 AND reviewer_id = $2",
|
||||
145 | | payload.submission_id,
|
||||
146 | | claims.sub
|
||||
147 | | )
|
||||
148 | | .fetch_optional(&pool)
|
||||
149 | | .await
|
||||
| |__________^ cannot infer type
|
||||
|
||||
error[E0282]: type annotations needed
|
||||
--> services/lms-service/src/handlers_peer_review.rs:150:15
|
||||
|
|
||||
150 | .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
| ^ - type must be known at this point
|
||||
|
|
||||
help: consider giving this closure parameter an explicit type
|
||||
|
|
||||
150 | .map_err(|e: /* Type */| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
| ++++++++++++
|
||||
|
||||
error[E0282]: type annotations needed
|
||||
--> services/lms-service/src/handlers_peer_review.rs:160:18
|
||||
|
|
||||
160 | let review = sqlx::query_as!(
|
||||
| __________________^
|
||||
161 | | PeerReview,
|
||||
162 | | r#"
|
||||
163 | | INSERT INTO peer_reviews (submission_id, reviewer_id, score, feedback, organization_id)
|
||||
... |
|
||||
173 | | .fetch_one(&pool)
|
||||
174 | | .await
|
||||
| |__________^ cannot infer type
|
||||
|
||||
error[E0282]: type annotations needed
|
||||
--> services/lms-service/src/handlers_peer_review.rs:175:15
|
||||
|
|
||||
175 | .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
| ^ - type must be known at this point
|
||||
|
|
||||
help: consider giving this closure parameter an explicit type
|
||||
|
|
||||
175 | .map_err(|e: /* Type */| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
| ++++++++++++
|
||||
|
||||
error[E0282]: type annotations needed
|
||||
--> services/lms-service/src/handlers_peer_review.rs:187:19
|
||||
|
|
||||
187 | let reviews = sqlx::query_as!(
|
||||
| ___________________^
|
||||
188 | | PeerReview,
|
||||
189 | | r#"
|
||||
190 | | SELECT pr.*
|
||||
... |
|
||||
198 | | .fetch_all(&pool)
|
||||
199 | | .await
|
||||
| |__________^ cannot infer type
|
||||
|
||||
error[E0282]: type annotations needed
|
||||
--> services/lms-service/src/handlers_peer_review.rs:200:15
|
||||
|
|
||||
200 | .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
| ^ - type must be known at this point
|
||||
|
|
||||
help: consider giving this closure parameter an explicit type
|
||||
|
|
||||
200 | .map_err(|e: /* Type */| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
| ++++++++++++
|
||||
|
||||
For more information about this error, try `rustc --explain E0282`.
|
||||
warning: `lms-service` (bin "lms-service") generated 1 warning
|
||||
error: could not compile `lms-service` (bin "lms-service") due to 40 previous errors; 1 warning emitted
|
||||
@@ -0,0 +1,11 @@
|
||||
-- Migration to support course previews and multi-instructor sync
|
||||
ALTER TABLE lessons ADD COLUMN is_previewable BOOLEAN NOT NULL DEFAULT FALSE;
|
||||
|
||||
CREATE TABLE course_instructors (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
course_id UUID NOT NULL REFERENCES courses(id) ON DELETE CASCADE,
|
||||
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
role TEXT NOT NULL DEFAULT 'instructor',
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
UNIQUE(course_id, user_id)
|
||||
);
|
||||
@@ -0,0 +1,15 @@
|
||||
-- Create user_bookmarks table for students to save lessons
|
||||
CREATE TABLE IF NOT EXISTS user_bookmarks (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
organization_id UUID NOT NULL,
|
||||
user_id UUID NOT NULL,
|
||||
course_id UUID NOT NULL,
|
||||
lesson_id UUID NOT NULL,
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||
UNIQUE(user_id, lesson_id)
|
||||
);
|
||||
|
||||
-- Index for efficient querying
|
||||
CREATE INDEX IF NOT EXISTS idx_user_bookmarks_user_id ON user_bookmarks(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_user_bookmarks_course_id ON user_bookmarks(course_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_user_bookmarks_org_id ON user_bookmarks(organization_id);
|
||||
@@ -0,0 +1,35 @@
|
||||
-- Migration: Add LTI 1.3 tables
|
||||
|
||||
CREATE TABLE IF NOT EXISTS lti_registrations (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
organization_id UUID NOT NULL REFERENCES organizations(id),
|
||||
issuer TEXT NOT NULL,
|
||||
client_id TEXT NOT NULL,
|
||||
deployment_id TEXT NOT NULL,
|
||||
auth_token_url TEXT NOT NULL,
|
||||
auth_login_url TEXT NOT NULL,
|
||||
jwks_url TEXT NOT NULL,
|
||||
platform_name TEXT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
UNIQUE(issuer, client_id, deployment_id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS lti_nonces (
|
||||
nonce TEXT PRIMARY KEY,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- Delete nonces older than 1 hour (can be run via cron or during launch)
|
||||
-- DELETE FROM lti_nonces WHERE created_at < NOW() - INTERVAL '1 hour';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS lti_resource_links (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
organization_id UUID NOT NULL REFERENCES organizations(id),
|
||||
resource_link_id TEXT NOT NULL,
|
||||
course_id UUID NOT NULL REFERENCES courses(id),
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
UNIQUE(organization_id, resource_link_id)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_lti_registrations_issuer_client ON lti_registrations(issuer, client_id);
|
||||
@@ -11,6 +11,30 @@ use common::models::{
|
||||
AuthResponse, Course, CourseAnalytics, Enrollment, HeatmapPoint, Lesson, LessonAnalytics,
|
||||
Module, Notification, Organization, RecommendationResponse, User, UserResponse,
|
||||
};
|
||||
|
||||
pub async fn get_me(
|
||||
claims: common::auth::Claims,
|
||||
State(pool): State<PgPool>,
|
||||
) -> Result<Json<UserResponse>, (StatusCode, String)> {
|
||||
let user = sqlx::query_as::<_, User>("SELECT * FROM users WHERE id = $1")
|
||||
.bind(claims.sub)
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
|
||||
Ok(Json(UserResponse {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
full_name: user.full_name,
|
||||
role: user.role,
|
||||
organization_id: user.organization_id,
|
||||
xp: user.xp,
|
||||
level: user.level,
|
||||
avatar_url: user.avatar_url,
|
||||
bio: user.bio,
|
||||
language: user.language,
|
||||
}))
|
||||
}
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sqlx::{PgPool, Row};
|
||||
use std::env;
|
||||
@@ -644,6 +668,12 @@ pub async fn ingest_course(
|
||||
.await
|
||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||
|
||||
sqlx::query("DELETE FROM course_instructors WHERE course_id = $1")
|
||||
.bind(payload.course.id)
|
||||
.execute(&mut *tx)
|
||||
.await
|
||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||
|
||||
// 3. Insert Grading Categories
|
||||
for cat in payload.grading_categories {
|
||||
sqlx::query(
|
||||
@@ -662,6 +692,27 @@ pub async fn ingest_course(
|
||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||
}
|
||||
|
||||
// 4. Insert Instructors
|
||||
if let Some(instructors) = payload.instructors {
|
||||
for instructor in instructors {
|
||||
sqlx::query(
|
||||
"INSERT INTO course_instructors (id, course_id, user_id, role, created_at)
|
||||
VALUES ($1, $2, $3, $4, $5)"
|
||||
)
|
||||
.bind(instructor.id)
|
||||
.bind(payload.course.id)
|
||||
.bind(instructor.user_id)
|
||||
.bind(&instructor.role)
|
||||
.bind(instructor.created_at)
|
||||
.execute(&mut *tx)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
tracing::error!("Failed to insert instructor: {}", e);
|
||||
StatusCode::INTERNAL_SERVER_ERROR
|
||||
})?;
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Insert Modules and Lessons
|
||||
for pub_module in &payload.modules {
|
||||
sqlx::query(
|
||||
@@ -680,8 +731,8 @@ pub async fn ingest_course(
|
||||
|
||||
for lesson in &pub_module.lessons {
|
||||
sqlx::query(
|
||||
"INSERT INTO lessons (id, module_id, title, content_type, content_url, transcription, metadata, position, created_at, is_graded, grading_category_id, max_attempts, allow_retry, organization_id, summary, due_date, important_date_type, transcription_status)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18)"
|
||||
"INSERT INTO lessons (id, module_id, title, content_type, content_url, transcription, metadata, position, created_at, is_graded, grading_category_id, max_attempts, allow_retry, organization_id, summary, due_date, important_date_type, transcription_status, is_previewable)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19)"
|
||||
)
|
||||
.bind(lesson.id)
|
||||
.bind(pub_module.module.id)
|
||||
@@ -701,6 +752,7 @@ pub async fn ingest_course(
|
||||
.bind(lesson.due_date)
|
||||
.bind(&lesson.important_date_type)
|
||||
.bind(&lesson.transcription_status)
|
||||
.bind(lesson.is_previewable)
|
||||
.execute(&mut *tx)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
@@ -858,11 +910,23 @@ pub async fn get_course_outline(
|
||||
StatusCode::INTERNAL_SERVER_ERROR
|
||||
})?;
|
||||
|
||||
// 7. Fetch Course Team
|
||||
let instructors = sqlx::query_as::<_, common::models::CourseInstructor>(
|
||||
"SELECT ci.*, u.email, u.full_name FROM course_instructors ci
|
||||
JOIN users u ON ci.user_id = u.id
|
||||
WHERE ci.course_id = $1"
|
||||
)
|
||||
.bind(id)
|
||||
.fetch_all(&pool)
|
||||
.await
|
||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||
|
||||
Ok(Json(common::models::PublishedCourse {
|
||||
course,
|
||||
organization,
|
||||
grading_categories,
|
||||
modules: pub_modules,
|
||||
instructors: Some(instructors),
|
||||
dependencies: Some(dependencies),
|
||||
}))
|
||||
}
|
||||
@@ -903,8 +967,8 @@ pub async fn get_lesson_content(
|
||||
sqlx::query_as::<_, Lesson>(
|
||||
"SELECT l.* FROM lessons l
|
||||
JOIN modules m ON l.module_id = m.id
|
||||
JOIN enrollments e ON m.course_id = e.course_id
|
||||
WHERE l.id = $1 AND e.user_id = $2",
|
||||
LEFT JOIN enrollments e ON m.course_id = e.course_id AND e.user_id = $2
|
||||
WHERE l.id = $1 AND (e.id IS NOT NULL OR l.is_previewable = true)",
|
||||
)
|
||||
.bind(id)
|
||||
.bind(claims.sub)
|
||||
@@ -1363,6 +1427,95 @@ pub async fn get_course_analytics(
|
||||
}))
|
||||
}
|
||||
|
||||
pub async fn get_student_progress_stats(
|
||||
Org(org_ctx): Org,
|
||||
claims: Claims,
|
||||
State(pool): State<PgPool>,
|
||||
Path(course_id): Path<Uuid>,
|
||||
) -> Result<Json<common::models::ProgressStats>, (StatusCode, String)> {
|
||||
let user_id = claims.sub;
|
||||
|
||||
// 1. Total Lessons
|
||||
let total_lessons: i64 = sqlx::query_scalar(
|
||||
"SELECT COUNT(*) FROM lessons WHERE organization_id = $1 AND module_id IN (SELECT id FROM modules WHERE course_id = $2)"
|
||||
)
|
||||
.bind(org_ctx.id)
|
||||
.bind(course_id)
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.unwrap_or(0);
|
||||
|
||||
// 2. Completed Lessons
|
||||
let completed_lessons: i64 = sqlx::query_scalar(
|
||||
"SELECT COUNT(*) FROM user_grades WHERE user_id = $1 AND course_id = $2 AND organization_id = $3",
|
||||
)
|
||||
.bind(user_id)
|
||||
.bind(course_id)
|
||||
.bind(org_ctx.id)
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.unwrap_or(0);
|
||||
|
||||
// 3. Daily Progress (Last 30 days)
|
||||
let daily_completions = sqlx::query_as::<_, common::models::DailyProgress>(
|
||||
r#"
|
||||
SELECT
|
||||
TO_CHAR(created_at, 'YYYY-MM-DD') as date,
|
||||
COUNT(*)::bigint as count
|
||||
FROM user_grades
|
||||
WHERE user_id = $1 AND course_id = $2 AND organization_id = $3
|
||||
AND created_at >= NOW() - INTERVAL '30 days'
|
||||
GROUP BY date
|
||||
ORDER BY date ASC
|
||||
"#
|
||||
)
|
||||
.bind(user_id)
|
||||
.bind(course_id)
|
||||
.bind(org_ctx.id)
|
||||
.fetch_all(&pool)
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
|
||||
// 4. Prediction Logic
|
||||
let first_entry: Option<chrono::DateTime<chrono::Utc>> = sqlx::query_scalar(
|
||||
"SELECT MIN(created_at) FROM user_grades WHERE user_id = $1 AND course_id = $2"
|
||||
)
|
||||
.bind(user_id)
|
||||
.bind(course_id)
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.unwrap_or(None);
|
||||
|
||||
let estimated_completion_date = if let Some(start) = first_entry {
|
||||
let days_passed = (chrono::Utc::now() - start).num_days().max(1) as f64;
|
||||
let pace = completed_lessons as f64 / days_passed;
|
||||
|
||||
if pace > 0.0 && total_lessons > completed_lessons {
|
||||
let remaining = (total_lessons - completed_lessons) as f64;
|
||||
let days_to_finish = (remaining / pace).ceil() as i64;
|
||||
Some(chrono::Utc::now() + chrono::Duration::days(days_to_finish))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let progress_percentage = if total_lessons > 0 {
|
||||
(completed_lessons as f32 / total_lessons as f32) * 100.0
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
|
||||
Ok(Json(common::models::ProgressStats {
|
||||
total_lessons,
|
||||
completed_lessons,
|
||||
progress_percentage,
|
||||
daily_completions,
|
||||
estimated_completion_date,
|
||||
}))
|
||||
}
|
||||
|
||||
pub async fn get_advanced_analytics(
|
||||
Org(org_ctx): Org,
|
||||
State(pool): State<PgPool>,
|
||||
@@ -1524,6 +1677,79 @@ pub async fn check_deadlines_and_notify(pool: PgPool) {
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn toggle_bookmark(
|
||||
Org(org_ctx): Org,
|
||||
claims: Claims,
|
||||
Path(lesson_id): Path<Uuid>,
|
||||
State(pool): State<PgPool>,
|
||||
) -> Result<StatusCode, (StatusCode, String)> {
|
||||
let user_id = claims.sub;
|
||||
|
||||
// 1. Get course_id from lesson
|
||||
let course_id: Uuid = sqlx::query_scalar(
|
||||
"SELECT m.course_id FROM lessons l JOIN modules m ON l.module_id = m.id WHERE l.id = $1"
|
||||
)
|
||||
.bind(lesson_id)
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.map_err(|_| (StatusCode::NOT_FOUND, "Lección no encontrada".to_string()))?;
|
||||
|
||||
// 2. Check if already bookmarked
|
||||
let existing_id: Option<Uuid> = sqlx::query_scalar(
|
||||
"SELECT id FROM user_bookmarks WHERE user_id = $1 AND lesson_id = $2"
|
||||
)
|
||||
.bind(user_id)
|
||||
.bind(lesson_id)
|
||||
.fetch_optional(&pool)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
|
||||
if let Some(id) = existing_id {
|
||||
// Remove bookmark
|
||||
sqlx::query("DELETE FROM user_bookmarks WHERE id = $1")
|
||||
.bind(id)
|
||||
.execute(&pool)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
} else {
|
||||
// Add bookmark
|
||||
sqlx::query(
|
||||
"INSERT INTO user_bookmarks (organization_id, user_id, course_id, lesson_id) VALUES ($1, $2, $3, $4)"
|
||||
)
|
||||
.bind(org_ctx.id)
|
||||
.bind(user_id)
|
||||
.bind(course_id)
|
||||
.bind(lesson_id)
|
||||
.execute(&pool)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
Ok(StatusCode::CREATED)
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn get_user_bookmarks(
|
||||
Org(org_ctx): Org,
|
||||
claims: Claims,
|
||||
State(pool): State<PgPool>,
|
||||
Query(filter): Query<common::models::AnalyticsFilter>,
|
||||
) -> Result<Json<Vec<common::models::UserBookmark>>, (StatusCode, String)> {
|
||||
let user_id = claims.sub;
|
||||
|
||||
let bookmarks = sqlx::query_as::<_, common::models::UserBookmark>(
|
||||
"SELECT * FROM user_bookmarks WHERE user_id = $1 AND organization_id = $2 AND ($3::uuid IS NULL OR course_id = $3) ORDER BY created_at DESC"
|
||||
)
|
||||
.bind(user_id)
|
||||
.bind(org_ctx.id)
|
||||
.bind(filter.cohort_id) // Reusing AnalyticsFilter which has cohort_id, but here we can use it for course_id or just ignore it.
|
||||
// Wait, let's create a better filter for this.
|
||||
.fetch_all(&pool)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
|
||||
Ok(Json(bookmarks))
|
||||
}
|
||||
|
||||
pub async fn update_user(
|
||||
Org(org_ctx): Org,
|
||||
claims: common::auth::Claims,
|
||||
|
||||
@@ -329,10 +329,10 @@ pub async fn create_post(
|
||||
.bind(thread_id)
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.map_err(|_| (StatusCode::NOT_FOUND, "Thread not found".to_string()))?;
|
||||
.map_err(|_| (StatusCode::NOT_FOUND, "Cohorte no encontrada".to_string()))?;
|
||||
|
||||
if thread.0 {
|
||||
return Err((StatusCode::FORBIDDEN, "Thread is locked".to_string()));
|
||||
return Err((StatusCode::FORBIDDEN, "El hilo está bloqueado".to_string()));
|
||||
}
|
||||
|
||||
let post = sqlx::query_as::<_, DiscussionPost>(
|
||||
@@ -392,7 +392,7 @@ pub async fn vote_post(
|
||||
Json(payload): Json<VotePayload>,
|
||||
) -> Result<StatusCode, (StatusCode, String)> {
|
||||
if payload.vote_type != "upvote" && payload.vote_type != "downvote" {
|
||||
return Err((StatusCode::BAD_REQUEST, "Invalid vote type".to_string()));
|
||||
return Err((StatusCode::BAD_REQUEST, "Tipo de voto inválido".to_string()));
|
||||
}
|
||||
|
||||
// Upsert vote
|
||||
|
||||
@@ -0,0 +1,255 @@
|
||||
use axum::{
|
||||
extract::{Query, State},
|
||||
http::StatusCode,
|
||||
response::{Redirect},
|
||||
Form,
|
||||
};
|
||||
use jsonwebtoken::{decode, decode_header, jwk::JwkSet, DecodingKey, Validation};
|
||||
use serde::{Deserialize};
|
||||
use sqlx::{PgPool};
|
||||
use uuid::Uuid;
|
||||
use common::models::{LtiLaunchClaims, LtiRegistration, LtiResourceLink, User};
|
||||
use common::auth::Claims;
|
||||
use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD};
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct LtiLoginParams {
|
||||
pub iss: String,
|
||||
pub login_hint: String,
|
||||
pub target_link_uri: String,
|
||||
pub lti_message_hint: Option<String>,
|
||||
pub client_id: Option<String>,
|
||||
pub lti_deployment_id: Option<String>,
|
||||
}
|
||||
|
||||
pub async fn lti_login_initiation(
|
||||
State(pool): State<PgPool>,
|
||||
Query(params): Query<LtiLoginParams>,
|
||||
) -> Result<Redirect, (StatusCode, String)> {
|
||||
// 1. Find registration
|
||||
let registration = sqlx::query_as::<_, LtiRegistration>(
|
||||
"SELECT * FROM lti_registrations WHERE issuer = $1 AND ($2::text IS NULL OR client_id = $2)"
|
||||
)
|
||||
.bind(¶ms.iss)
|
||||
.bind(¶ms.client_id)
|
||||
.fetch_optional(&pool)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
|
||||
.ok_or((StatusCode::BAD_REQUEST, "LTI Registration not found".to_string()))?;
|
||||
|
||||
// 2. Generate state and nonce
|
||||
let state = Uuid::new_v4().to_string();
|
||||
let nonce = Uuid::new_v4().to_string();
|
||||
|
||||
// 3. Store nonce
|
||||
sqlx::query("INSERT INTO lti_nonces (nonce) VALUES ($1)")
|
||||
.bind(&nonce)
|
||||
.execute(&pool)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
|
||||
// 4. Construct redirect URL
|
||||
let mut url = format!(
|
||||
"{}?scope=openid&response_type=id_token&client_id={}&redirect_uri={}&login_hint={}&state={}&nonce={}&response_mode=form_post",
|
||||
registration.auth_login_url,
|
||||
registration.client_id,
|
||||
urlencoding::encode(¶ms.target_link_uri),
|
||||
urlencoding::encode(¶ms.login_hint),
|
||||
state,
|
||||
nonce
|
||||
);
|
||||
|
||||
if let Some(hint) = params.lti_message_hint {
|
||||
url.push_str(&format!("<i_message_hint={}", urlencoding::encode(&hint)));
|
||||
}
|
||||
|
||||
Ok(Redirect::to(&url))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct LtiLaunchParams {
|
||||
pub id_token: String,
|
||||
pub state: String,
|
||||
}
|
||||
|
||||
pub async fn validate_lti_jwt(
|
||||
id_token: &str,
|
||||
jwks_url: &str,
|
||||
client_id: &str,
|
||||
) -> Result<LtiLaunchClaims, String> {
|
||||
let header = decode_header(id_token).map_err(|e| e.to_string())?;
|
||||
let kid = header.kid.ok_or("Missing kid in JWT header")?;
|
||||
|
||||
// Fetch JWKS
|
||||
let jwks: JwkSet = reqwest::get(jwks_url)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
let jwk = jwks.find(&kid).ok_or("JWK not found for kid")?;
|
||||
let decoding_key = DecodingKey::from_jwk(jwk).map_err(|e| e.to_string())?;
|
||||
|
||||
let mut validation = Validation::new(jsonwebtoken::Algorithm::RS256);
|
||||
validation.set_audience(&[client_id]);
|
||||
|
||||
let token_data = decode::<LtiLaunchClaims>(id_token, &decoding_key, &validation)
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
Ok(token_data.claims)
|
||||
}
|
||||
|
||||
pub async fn lti_launch(
|
||||
State(pool): State<PgPool>,
|
||||
Form(payload): Form<LtiLaunchParams>,
|
||||
) -> Result<Redirect, (StatusCode, String)> {
|
||||
// 1. Decode claims manually to find registration (since we don't have the key yet)
|
||||
let parts: Vec<&str> = payload.id_token.split('.').collect();
|
||||
if parts.len() != 3 {
|
||||
return Err((StatusCode::BAD_REQUEST, "Invalid JWT format".to_string()));
|
||||
}
|
||||
|
||||
let decoded_claims = URL_SAFE_NO_PAD.decode(parts[1])
|
||||
.map_err(|e| (StatusCode::BAD_REQUEST, format!("Invalid base64 in JWT payload: {}", e)))?;
|
||||
|
||||
let claims: serde_json::Value = serde_json::from_slice(&decoded_claims)
|
||||
.map_err(|e| (StatusCode::BAD_REQUEST, format!("Invalid JSON in JWT payload: {}", e)))?;
|
||||
|
||||
let iss = claims["iss"].as_str().ok_or((StatusCode::BAD_REQUEST, "Missing iss claim".to_string()))?;
|
||||
let aud_val = &claims["aud"];
|
||||
let aud = match aud_val {
|
||||
serde_json::Value::String(s) => s.as_str(),
|
||||
serde_json::Value::Array(arr) => arr[0].as_str().ok_or((StatusCode::BAD_REQUEST, "Invalid aud in array".to_string()))?,
|
||||
_ => return Err((StatusCode::BAD_REQUEST, "Invalid aud claim".to_string())),
|
||||
};
|
||||
|
||||
// 2. Find registration
|
||||
let registration = sqlx::query_as::<_, LtiRegistration>(
|
||||
"SELECT * FROM lti_registrations WHERE issuer = $1 AND client_id = $2"
|
||||
)
|
||||
.bind(iss)
|
||||
.bind(aud)
|
||||
.fetch_optional(&pool)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
|
||||
.ok_or((StatusCode::NOT_FOUND, "LTI Registration not found for issuer/aud".to_string()))?;
|
||||
|
||||
// 3. Validate JWT
|
||||
let lti_claims = validate_lti_jwt(&payload.id_token, ®istration.jwks_url, ®istration.client_id)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::UNAUTHORIZED, format!("JWT validation failed: {}", e)))?;
|
||||
|
||||
// 4. Verify nonce
|
||||
let nonce_exists = sqlx::query("DELETE FROM lti_nonces WHERE nonce = $1")
|
||||
.bind(<i_claims.nonce)
|
||||
.execute(&pool)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
|
||||
.rows_affected() > 0;
|
||||
|
||||
if !nonce_exists {
|
||||
return Err((StatusCode::BAD_REQUEST, "Invalid or expired nonce".to_string()));
|
||||
}
|
||||
|
||||
// 5. Find or create user
|
||||
let email = lti_claims.email.clone().unwrap_or_else(|| format!("lti_{}@{}", lti_claims.subject, iss.replace("http://", "").replace("https://", "")));
|
||||
let full_name = lti_claims.name.clone().unwrap_or_else(|| "LTI User".to_string());
|
||||
|
||||
let mut user = sqlx::query_as::<_, User>(
|
||||
"SELECT * FROM users WHERE email = $1 AND organization_id = $2"
|
||||
)
|
||||
.bind(&email)
|
||||
.bind(registration.organization_id)
|
||||
.fetch_optional(&pool)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
|
||||
if user.is_none() {
|
||||
let new_user_id = Uuid::new_v4();
|
||||
let role = if lti_claims.roles.iter().any(|r| r.contains("Instructor") || r.contains("Administrator")) {
|
||||
"instructor"
|
||||
} else {
|
||||
"student"
|
||||
};
|
||||
|
||||
sqlx::query(
|
||||
"INSERT INTO users (id, organization_id, email, password_hash, full_name, role) VALUES ($1, $2, $3, $4, $5, $6)"
|
||||
)
|
||||
.bind(new_user_id)
|
||||
.bind(registration.organization_id)
|
||||
.bind(&email)
|
||||
.bind("")
|
||||
.bind(&full_name)
|
||||
.bind(role)
|
||||
.execute(&pool)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
|
||||
user = Some(User {
|
||||
id: new_user_id,
|
||||
organization_id: registration.organization_id,
|
||||
email: email.clone(),
|
||||
password_hash: "".to_string(),
|
||||
full_name: full_name.clone(),
|
||||
role: role.to_string(),
|
||||
xp: 0,
|
||||
level: 1,
|
||||
avatar_url: None,
|
||||
bio: None,
|
||||
language: None,
|
||||
created_at: chrono::Utc::now(),
|
||||
updated_at: chrono::Utc::now(),
|
||||
});
|
||||
}
|
||||
|
||||
let user = user.unwrap();
|
||||
|
||||
// 6. Map resource link to course
|
||||
let resource_link = sqlx::query_as::<_, LtiResourceLink>(
|
||||
"SELECT * FROM lti_resource_links WHERE organization_id = $1 AND resource_link_id = $2"
|
||||
)
|
||||
.bind(registration.organization_id)
|
||||
.bind(<i_claims.resource_link.id)
|
||||
.fetch_optional(&pool)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
|
||||
let redirect_target = if let Some(link) = resource_link {
|
||||
sqlx::query(
|
||||
"INSERT INTO enrollments (user_id, organization_id, course_id) VALUES ($1, $2, $3) ON CONFLICT DO NOTHING"
|
||||
)
|
||||
.bind(user.id)
|
||||
.bind(registration.organization_id)
|
||||
.bind(link.course_id)
|
||||
.execute(&pool)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
|
||||
format!("/courses/{}", link.course_id)
|
||||
} else {
|
||||
"/dashboard".to_string()
|
||||
};
|
||||
|
||||
// 7. Generate JWT
|
||||
let claims = Claims {
|
||||
sub: user.id,
|
||||
role: user.role,
|
||||
org: user.organization_id,
|
||||
exp: (chrono::Utc::now() + chrono::Duration::hours(24)).timestamp(),
|
||||
course_id: None,
|
||||
token_type: Some("access".to_string()),
|
||||
};
|
||||
|
||||
let secret = std::env::var("JWT_SECRET").unwrap_or_else(|_| "secret".to_string());
|
||||
let token = jsonwebtoken::encode(
|
||||
&jsonwebtoken::Header::default(),
|
||||
&claims,
|
||||
&jsonwebtoken::EncodingKey::from_secret(secret.as_bytes()),
|
||||
)
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
|
||||
// 8. Redirect to Experience app launch page
|
||||
let experience_url = std::env::var("NEXT_PUBLIC_EXPERIENCE_URL").unwrap_or_else(|_| "http://localhost:3000".to_string());
|
||||
Ok(Redirect::to(&format!("{}/lti/launch?token={}&target={}", experience_url, token, urlencoding::encode(&redirect_target))))
|
||||
}
|
||||
@@ -6,6 +6,7 @@ mod handlers_discussions;
|
||||
mod handlers_notes;
|
||||
mod handlers_payments;
|
||||
mod handlers_peer_review;
|
||||
mod lti;
|
||||
|
||||
use axum::{
|
||||
Router, middleware,
|
||||
@@ -50,6 +51,7 @@ async fn main() {
|
||||
.allow_headers(Any);
|
||||
|
||||
let protected_routes = Router::new()
|
||||
.route("/auth/me", get(handlers::get_me))
|
||||
.route("/enroll", post(handlers::enroll_user))
|
||||
.route("/bulk-enroll", post(handlers::bulk_enroll_users))
|
||||
.route("/enrollments/{id}", get(handlers::get_user_enrollments))
|
||||
@@ -58,7 +60,10 @@ async fn main() {
|
||||
post(handlers_payments::create_payment_preference),
|
||||
)
|
||||
.route("/courses/{id}/outline", get(handlers::get_course_outline))
|
||||
.route("/courses/{id}/progress-stats", get(handlers::get_student_progress_stats))
|
||||
.route("/lessons/{id}", get(handlers::get_lesson_content))
|
||||
.route("/lessons/{id}/bookmark", post(handlers::toggle_bookmark))
|
||||
.route("/bookmarks", get(handlers::get_user_bookmarks))
|
||||
.route("/grades", post(handlers::submit_lesson_score))
|
||||
.route(
|
||||
"/users/{user_id}/courses/{course_id}/grades",
|
||||
@@ -203,6 +208,8 @@ async fn main() {
|
||||
"/payments/mercadopago/webhook",
|
||||
post(handlers_payments::mercadopago_webhook),
|
||||
)
|
||||
.route("/lti/login", get(lti::lti_login_initiation))
|
||||
.route("/lti/launch", post(lti::lti_launch))
|
||||
.merge(protected_routes)
|
||||
.layer(cors)
|
||||
.with_state(pool);
|
||||
|
||||
Reference in New Issue
Block a user