feat: SAM integration, deployment scripts, and audio response enhancements
- Add SAM (Sistema de Administración Académica) integration with sync endpoints - Add deployment automation (deploy.sh, remote-setup.sh, setup-nginx-ssl.sh) - Add nginx proxy configuration for SSL with Let's Encrypt - Add audio response support for student lessons (migrations, handlers) - Add audio evaluations admin page - Update CORS to support wildcard subdomains for norteamericano.cl - Add comprehensive deployment documentation (DESPLIEGUE.md, ManualDeConfiguracion.md) - Update docker-compose.yml with nginx-proxy and acme-companion services - Remove outdated documentation files Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
This commit is contained in:
@@ -1,7 +1,29 @@
|
||||
-- AI Usage Logs: Update log_ai_usage function to include prompt and response
|
||||
-- Note: prompt and response columns already exist from previous migration
|
||||
-- AI Usage Logs: Add prompt and response columns
|
||||
-- First, add columns if they don't exist
|
||||
|
||||
-- Update log_ai_usage function to accept prompt and response
|
||||
-- Add prompt column
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM information_schema.columns
|
||||
WHERE table_name = 'ai_usage_logs' AND column_name = 'prompt'
|
||||
) THEN
|
||||
ALTER TABLE ai_usage_logs ADD COLUMN prompt TEXT;
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
-- Add response column
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM information_schema.columns
|
||||
WHERE table_name = 'ai_usage_logs' AND column_name = 'response'
|
||||
) THEN
|
||||
ALTER TABLE ai_usage_logs ADD COLUMN response TEXT;
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
-- Update log_ai_usage function to include prompt and response
|
||||
CREATE OR REPLACE FUNCTION log_ai_usage(
|
||||
p_user_id UUID,
|
||||
p_org_id UUID,
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
-- Add SAM integration fields to users table
|
||||
-- This allows tracking students imported from the SAM system
|
||||
|
||||
-- Add SAM student ID column (nullable for non-SAM users)
|
||||
ALTER TABLE users
|
||||
ADD COLUMN sam_student_id VARCHAR(50),
|
||||
ADD COLUMN is_sam_student BOOLEAN DEFAULT FALSE,
|
||||
ADD COLUMN sam_verified_at TIMESTAMPTZ;
|
||||
|
||||
-- Create index for faster SAM lookups
|
||||
CREATE INDEX IF NOT EXISTS idx_users_sam_student_id ON users(sam_student_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_users_is_sam_student ON users(is_sam_student);
|
||||
|
||||
-- Create table to track SAM course assignments (cached from sige_sam_v3.detalle_contrato)
|
||||
CREATE TABLE IF NOT EXISTS sam_course_assignments (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
sam_student_id VARCHAR(50) NOT NULL,
|
||||
sam_contrato_id VARCHAR(50),
|
||||
course_id UUID NOT NULL REFERENCES courses(id) ON DELETE CASCADE,
|
||||
is_active BOOLEAN DEFAULT TRUE,
|
||||
synced_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
UNIQUE(sam_student_id, course_id)
|
||||
);
|
||||
|
||||
-- Indexes for fast lookups
|
||||
CREATE INDEX IF NOT EXISTS idx_sam_assignments_student ON sam_course_assignments(sam_student_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_sam_assignments_course ON sam_course_assignments(course_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_sam_assignments_active ON sam_course_assignments(is_active);
|
||||
|
||||
-- Trigger to update updated_at
|
||||
CREATE OR REPLACE FUNCTION update_sam_assignment_updated_at()
|
||||
RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
NEW.updated_at = NOW();
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
CREATE TRIGGER trg_sam_assignment_updated_at
|
||||
BEFORE UPDATE ON sam_course_assignments
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION update_sam_assignment_updated_at();
|
||||
|
||||
-- Comment describing the integration
|
||||
COMMENT ON TABLE sam_course_assignments IS 'Cache of course assignments from sige_sam_v3.detalle_contrato. Links SAM students to OpenCCB courses.';
|
||||
COMMENT ON COLUMN users.sam_student_id IS 'Student ID from sige_sam_v3.alumnos (nombre column)';
|
||||
COMMENT ON COLUMN users.is_sam_student IS 'True if this user was imported/managed by SAM system';
|
||||
@@ -339,15 +339,42 @@ pub async fn get_courses(
|
||||
let is_super_admin = claims.role == "admin"
|
||||
&& claims.org == Uuid::parse_str("00000000-0000-0000-0000-000000000001").unwrap();
|
||||
|
||||
let courses = if is_super_admin {
|
||||
// Check if user is a SAM student
|
||||
let is_sam_student: bool = sqlx::query_scalar(
|
||||
"SELECT COALESCE(is_sam_student, false) FROM users WHERE id = $1"
|
||||
)
|
||||
.bind(claims.sub)
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.unwrap_or(false);
|
||||
|
||||
let courses: Vec<Course> = if is_super_admin {
|
||||
// Super admin sees all courses
|
||||
sqlx::query_as::<_, Course>("SELECT * FROM courses")
|
||||
.fetch_all(&pool)
|
||||
.await
|
||||
} else {
|
||||
} else if claims.role == "admin" || claims.role == "instructor" {
|
||||
// Admins and instructors see all courses in their organization
|
||||
sqlx::query_as::<_, Course>("SELECT * FROM courses WHERE organization_id = $1")
|
||||
.bind(org_ctx.id)
|
||||
.fetch_all(&pool)
|
||||
.await
|
||||
} else if is_sam_student {
|
||||
// SAM students only see courses they are enrolled in via SAM
|
||||
sqlx::query_as::<_, Course>(
|
||||
"SELECT c.* FROM courses c
|
||||
INNER JOIN sam_course_assignments sca ON c.id = sca.course_id
|
||||
WHERE sca.sam_student_id = (SELECT sam_student_id FROM users WHERE id = $1)
|
||||
AND sca.is_active = TRUE
|
||||
AND c.organization_id = $2"
|
||||
)
|
||||
.bind(claims.sub)
|
||||
.bind(org_ctx.id)
|
||||
.fetch_all(&pool)
|
||||
.await
|
||||
} else {
|
||||
// Non-SAM students see NO courses (empty list)
|
||||
return Ok(Json(Vec::new()));
|
||||
}
|
||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||
|
||||
|
||||
@@ -0,0 +1,522 @@
|
||||
/// SAM (Sistema de Administración Académica) Integration Handlers
|
||||
///
|
||||
/// This module handles synchronization between OpenCCB and the external SAM system.
|
||||
/// SAM tables: sige_sam_v3.alumnos, sige_sam_v3.detalle_contrato
|
||||
|
||||
use axum::{
|
||||
extract::{Path, Query, State},
|
||||
http::StatusCode,
|
||||
Json,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json;
|
||||
use sqlx::{PgPool, Row};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::handlers::Claims;
|
||||
use crate::handlers::Org;
|
||||
|
||||
/// SAM Student info from external database
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct SamStudentInfo {
|
||||
pub id_alumno: String,
|
||||
pub nombre: String,
|
||||
pub email: String,
|
||||
pub telefono: Option<String>,
|
||||
pub activo: bool,
|
||||
}
|
||||
|
||||
/// SAM Course Assignment from detalle_contrato
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct SamAssignmentInfo {
|
||||
pub id_contrato: String,
|
||||
pub id_alumno: String,
|
||||
pub id_curso_abierto: i32,
|
||||
pub estado: String,
|
||||
}
|
||||
|
||||
/// Response for sync operation
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct SamSyncResponse {
|
||||
pub students_synced: usize,
|
||||
pub assignments_synced: usize,
|
||||
pub errors: Vec<String>,
|
||||
}
|
||||
|
||||
/// Filters for SAM queries
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct SamStudentFilters {
|
||||
pub email: Option<String>,
|
||||
pub nombre: Option<String>,
|
||||
}
|
||||
|
||||
/// ==================== SAM Sync Handlers ====================
|
||||
|
||||
/// POST /api/sam/sync-students
|
||||
/// Sync students from sige_sam_v3.alumnos to OpenCCB users table
|
||||
pub async fn sync_sam_students(
|
||||
_org: Org,
|
||||
_claims: Claims,
|
||||
State(pool): State<PgPool>,
|
||||
) -> Result<Json<SamSyncResponse>, (StatusCode, String)> {
|
||||
// Connect to external SAM database
|
||||
let sam_url = std::env::var("SAM_DATABASE_URL")
|
||||
.map_err(|_| (StatusCode::INTERNAL_SERVER_ERROR, "SAM_DATABASE_URL not configured".to_string()))?;
|
||||
|
||||
let sam_pool = sqlx::PgPool::connect(&sam_url)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Failed to connect to SAM DB: {}", e)))?;
|
||||
|
||||
let mut errors = Vec::new();
|
||||
let mut students_synced = 0;
|
||||
|
||||
// Fetch active students from SAM using generic query
|
||||
let rows = sqlx::query(
|
||||
r#"
|
||||
SELECT
|
||||
id_alumno,
|
||||
nombre,
|
||||
email,
|
||||
telefono,
|
||||
activo
|
||||
FROM sige_sam_v3.alumnos
|
||||
WHERE activo = true AND email IS NOT NULL
|
||||
"#
|
||||
)
|
||||
.fetch_all(&sam_pool)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Failed to fetch SAM students: {}", e)))?;
|
||||
|
||||
// Convert to SamStudentInfo
|
||||
let sam_students: Vec<SamStudentInfo> = rows.iter().map(|row| {
|
||||
SamStudentInfo {
|
||||
id_alumno: row.get("id_alumno"),
|
||||
nombre: row.get("nombre"),
|
||||
email: row.get("email"),
|
||||
telefono: row.get::<Option<String>, _>("telefono"),
|
||||
activo: row.get("activo"),
|
||||
}
|
||||
}).collect();
|
||||
|
||||
// Sync each student to OpenCCB
|
||||
for sam_student in sam_students {
|
||||
// Check if user exists by email
|
||||
let existing_user: Option<(Uuid, Option<String>)> = sqlx::query_as(
|
||||
"SELECT id, sam_student_id FROM users WHERE email = $1"
|
||||
)
|
||||
.bind(&sam_student.email)
|
||||
.fetch_optional(&pool)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
errors.push(format!("Error checking user {}: {}", sam_student.email, e));
|
||||
(StatusCode::INTERNAL_SERVER_ERROR, e.to_string())
|
||||
})
|
||||
.ok()
|
||||
.flatten();
|
||||
|
||||
match existing_user {
|
||||
Some((user_id, existing_sam_id)) => {
|
||||
// Update existing user with SAM info
|
||||
let update_result = sqlx::query(
|
||||
r#"
|
||||
UPDATE users
|
||||
SET sam_student_id = $1,
|
||||
is_sam_student = TRUE,
|
||||
sam_verified_at = NOW(),
|
||||
full_name = COALESCE($2, full_name)
|
||||
WHERE id = $3
|
||||
"#
|
||||
)
|
||||
.bind(&sam_student.id_alumno)
|
||||
.bind(&sam_student.nombre)
|
||||
.bind(user_id)
|
||||
.execute(&pool)
|
||||
.await;
|
||||
|
||||
if update_result.is_ok() {
|
||||
students_synced += 1;
|
||||
} else {
|
||||
errors.push(format!("Failed to update user {}", sam_student.email));
|
||||
}
|
||||
}
|
||||
None => {
|
||||
// Create new user for SAM student
|
||||
let insert_result = sqlx::query(
|
||||
r#"
|
||||
INSERT INTO users (
|
||||
email,
|
||||
password_hash,
|
||||
full_name,
|
||||
role,
|
||||
organization_id,
|
||||
sam_student_id,
|
||||
is_sam_student,
|
||||
sam_verified_at
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, TRUE, NOW())
|
||||
RETURNING id
|
||||
"#
|
||||
)
|
||||
.bind(&sam_student.email)
|
||||
.bind(format!("sam_managed_{}", sam_student.id_alumno)) // Placeholder password
|
||||
.bind(&sam_student.nombre)
|
||||
.bind("student")
|
||||
.bind(Uuid::parse_str("00000000-0000-0000-0000-000000000001").unwrap()) // Default org
|
||||
.bind(&sam_student.id_alumno)
|
||||
.fetch_optional(&pool)
|
||||
.await;
|
||||
|
||||
match insert_result {
|
||||
Ok(Some(_)) => students_synced += 1,
|
||||
Ok(None) => errors.push(format!("Failed to create user for {}", sam_student.email)),
|
||||
Err(e) => errors.push(format!("Error creating user {}: {}", sam_student.email, e)),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
sam_pool.close().await;
|
||||
|
||||
Ok(Json(SamSyncResponse {
|
||||
students_synced,
|
||||
assignments_synced: 0,
|
||||
errors,
|
||||
}))
|
||||
}
|
||||
|
||||
/// POST /api/sam/sync-assignments
|
||||
/// Sync course assignments from sige_sam_v3.detalle_contrato
|
||||
pub async fn sync_sam_assignments(
|
||||
_org: Org,
|
||||
_claims: Claims,
|
||||
State(pool): State<PgPool>,
|
||||
) -> Result<Json<SamSyncResponse>, (StatusCode, String)> {
|
||||
// Connect to external SAM database
|
||||
let sam_url = std::env::var("SAM_DATABASE_URL")
|
||||
.map_err(|_| (StatusCode::INTERNAL_SERVER_ERROR, "SAM_DATABASE_URL not configured".to_string()))?;
|
||||
|
||||
let sam_pool = sqlx::PgPool::connect(&sam_url)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Failed to connect to SAM DB: {}", e)))?;
|
||||
|
||||
let mut errors = Vec::new();
|
||||
let mut assignments_synced = 0;
|
||||
|
||||
// Fetch active course assignments from SAM
|
||||
let rows = sqlx::query(
|
||||
r#"
|
||||
SELECT
|
||||
id_contrato,
|
||||
id_alumno,
|
||||
id_curso_abierto,
|
||||
estado
|
||||
FROM sige_sam_v3.detalle_contrato
|
||||
WHERE estado = 'activo' OR estado = 'vigente'
|
||||
"#
|
||||
)
|
||||
.fetch_all(&sam_pool)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Failed to fetch assignments: {}", e)))?;
|
||||
|
||||
// Convert to SamAssignmentInfo
|
||||
let sam_assignments: Vec<SamAssignmentInfo> = rows.iter().map(|row| {
|
||||
SamAssignmentInfo {
|
||||
id_contrato: row.get("id_contrato"),
|
||||
id_alumno: row.get("id_alumno"),
|
||||
id_curso_abierto: row.get("id_curso_abierto"),
|
||||
estado: row.get("estado"),
|
||||
}
|
||||
}).collect();
|
||||
|
||||
// Sync each assignment
|
||||
for assignment in sam_assignments {
|
||||
// Get the OpenCCB course ID from SAM course ID
|
||||
// This assumes you have a mapping table or the SAM course ID matches OpenCCB course external_id
|
||||
let course_result = sqlx::query_as::<_, (Uuid,)>(
|
||||
"SELECT id FROM courses WHERE external_sam_id = $1 OR id = $2"
|
||||
)
|
||||
.bind(assignment.id_curso_abierto as i64)
|
||||
.bind(assignment.id_curso_abierto)
|
||||
.fetch_optional(&pool)
|
||||
.await;
|
||||
|
||||
let course_id = match course_result {
|
||||
Ok(Some((id,))) => Some(id),
|
||||
Ok(None) => None,
|
||||
Err(e) => {
|
||||
errors.push(format!("Error finding course {}: {}", assignment.id_curso_abierto, e));
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
if let Some(course_id) = course_id {
|
||||
// Upsert assignment
|
||||
let upsert_result = sqlx::query(
|
||||
r#"
|
||||
INSERT INTO sam_course_assignments (sam_student_id, sam_contrato_id, course_id, is_active, synced_at)
|
||||
VALUES ($1, $2, $3, TRUE, NOW())
|
||||
ON CONFLICT (sam_student_id, course_id)
|
||||
DO UPDATE SET
|
||||
is_active = TRUE,
|
||||
sam_contrato_id = EXCLUDED.sam_contrato_id,
|
||||
synced_at = NOW()
|
||||
"#
|
||||
)
|
||||
.bind(&assignment.id_alumno)
|
||||
.bind(&assignment.id_contrato)
|
||||
.bind(course_id)
|
||||
.execute(&pool)
|
||||
.await;
|
||||
|
||||
if upsert_result.is_ok() {
|
||||
assignments_synced += 1;
|
||||
} else {
|
||||
errors.push(format!("Failed to sync assignment for student {}", assignment.id_alumno));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
sam_pool.close().await;
|
||||
|
||||
Ok(Json(SamSyncResponse {
|
||||
students_synced: 0,
|
||||
assignments_synced,
|
||||
errors,
|
||||
}))
|
||||
}
|
||||
|
||||
/// GET /api/sam/students
|
||||
/// List SAM students with optional filters
|
||||
pub async fn list_sam_students(
|
||||
_org: Org,
|
||||
_claims: Claims,
|
||||
State(pool): State<PgPool>,
|
||||
Query(filters): Query<SamStudentFilters>,
|
||||
) -> Result<Json<Vec<serde_json::Value>>, (StatusCode, String)> {
|
||||
let mut query = r#"
|
||||
SELECT
|
||||
u.id,
|
||||
u.email,
|
||||
u.full_name,
|
||||
u.sam_student_id,
|
||||
u.is_sam_student,
|
||||
u.sam_verified_at,
|
||||
u.created_at
|
||||
FROM users u
|
||||
WHERE u.is_sam_student = TRUE
|
||||
"#.to_string();
|
||||
|
||||
if let Some(email) = filters.email {
|
||||
query.push_str(&format!(" AND u.email ILIKE '%{}%'", email));
|
||||
}
|
||||
|
||||
if let Some(nombre) = filters.nombre {
|
||||
query.push_str(&format!(" AND u.full_name ILIKE '%{}%'", nombre));
|
||||
}
|
||||
|
||||
query.push_str(" ORDER BY u.full_name");
|
||||
|
||||
let rows = sqlx::query(&query)
|
||||
.fetch_all(&pool)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Failed to fetch students: {}", e)))?;
|
||||
|
||||
let students: Vec<serde_json::Value> = rows.iter().map(|row| {
|
||||
serde_json::json!({
|
||||
"id": row.get::<Uuid, _>("id"),
|
||||
"email": row.get::<String, _>("email"),
|
||||
"full_name": row.get::<String, _>("full_name"),
|
||||
"sam_student_id": row.get::<String, _>("sam_student_id"),
|
||||
"is_sam_student": row.get::<bool, _>("is_sam_student"),
|
||||
"sam_verified_at": row.get::<Option<chrono::DateTime<chrono::Utc>>, _>("sam_verified_at"),
|
||||
"created_at": row.get::<chrono::DateTime<chrono::Utc>, _>("created_at"),
|
||||
})
|
||||
}).collect();
|
||||
|
||||
Ok(Json(students))
|
||||
}
|
||||
|
||||
/// GET /api/sam/students/{student_id}/courses
|
||||
/// Get courses assigned to a specific SAM student
|
||||
pub async fn get_sam_student_courses(
|
||||
_org: Org,
|
||||
_claims: Claims,
|
||||
State(pool): State<PgPool>,
|
||||
Path(sam_student_id): Path<String>,
|
||||
) -> Result<Json<Vec<serde_json::Value>>, (StatusCode, String)> {
|
||||
let rows = sqlx::query(
|
||||
r#"
|
||||
SELECT c.*, sca.is_active, sca.synced_at
|
||||
FROM courses c
|
||||
INNER JOIN sam_course_assignments sca ON c.id = sca.course_id
|
||||
WHERE sca.sam_student_id = $1 AND sca.is_active = TRUE
|
||||
ORDER BY c.title
|
||||
"#
|
||||
)
|
||||
.bind(&sam_student_id)
|
||||
.fetch_all(&pool)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Failed to fetch courses: {}", e)))?;
|
||||
|
||||
let courses: Vec<serde_json::Value> = rows.iter().map(|row| {
|
||||
serde_json::json!({
|
||||
"id": row.get::<Uuid, _>("id"),
|
||||
"title": row.get::<String, _>("title"),
|
||||
"description": row.get::<Option<String>, _>("description"),
|
||||
"is_active": row.get::<bool, _>("is_active"),
|
||||
"synced_at": row.get::<Option<chrono::DateTime<chrono::Utc>>, _>("synced_at"),
|
||||
})
|
||||
}).collect();
|
||||
|
||||
Ok(Json(courses))
|
||||
}
|
||||
|
||||
/// POST /api/sam/sync-all
|
||||
/// Full synchronization: students + assignments
|
||||
pub async fn sync_all_sam(
|
||||
org: Org,
|
||||
claims: Claims,
|
||||
State(pool): State<PgPool>,
|
||||
) -> Result<Json<SamSyncResponse>, (StatusCode, String)> {
|
||||
let mut errors = Vec::new();
|
||||
let mut students_synced = 0;
|
||||
let mut assignments_synced = 0;
|
||||
|
||||
// Connect to external SAM database
|
||||
let sam_url = std::env::var("SAM_DATABASE_URL")
|
||||
.map_err(|_| (StatusCode::INTERNAL_SERVER_ERROR, "SAM_DATABASE_URL not configured".to_string()))?;
|
||||
|
||||
let sam_pool = sqlx::PgPool::connect(&sam_url)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Failed to connect to SAM DB: {}", e)))?;
|
||||
|
||||
// Sync students first
|
||||
{
|
||||
// Fetch active students from SAM
|
||||
let rows = sqlx::query(
|
||||
r#"
|
||||
SELECT id_alumno, nombre, email, telefono, activo
|
||||
FROM sige_sam_v3.alumnos
|
||||
WHERE activo = true AND email IS NOT NULL
|
||||
"#
|
||||
)
|
||||
.fetch_all(&sam_pool)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Failed to fetch SAM students: {}", e)))?;
|
||||
|
||||
let sam_students: Vec<SamStudentInfo> = rows.iter().map(|row| {
|
||||
SamStudentInfo {
|
||||
id_alumno: row.get("id_alumno"),
|
||||
nombre: row.get("nombre"),
|
||||
email: row.get("email"),
|
||||
telefono: row.get::<Option<String>, _>("telefono"),
|
||||
activo: row.get("activo"),
|
||||
}
|
||||
}).collect();
|
||||
|
||||
// Sync each student
|
||||
for sam_student in sam_students {
|
||||
let existing_user: Option<(Uuid, Option<String>)> = sqlx::query_as(
|
||||
"SELECT id, sam_student_id FROM users WHERE email = $1"
|
||||
)
|
||||
.bind(&sam_student.email)
|
||||
.fetch_optional(&pool)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
errors.push(format!("Error checking user {}: {}", sam_student.email, e));
|
||||
(StatusCode::INTERNAL_SERVER_ERROR, e.to_string())
|
||||
})
|
||||
.ok()
|
||||
.flatten();
|
||||
|
||||
match existing_user {
|
||||
Some((user_id, _existing_sam_id)) => {
|
||||
let update_result = sqlx::query(
|
||||
"UPDATE users SET sam_student_id = $1, is_sam_student = TRUE, sam_verified_at = NOW(), full_name = COALESCE($2, full_name) WHERE id = $3"
|
||||
)
|
||||
.bind(&sam_student.id_alumno)
|
||||
.bind(&sam_student.nombre)
|
||||
.bind(user_id)
|
||||
.execute(&pool)
|
||||
.await;
|
||||
|
||||
if update_result.is_ok() { students_synced += 1; } else { errors.push(format!("Failed to update user {}", sam_student.email)); }
|
||||
}
|
||||
None => {
|
||||
let insert_result = sqlx::query(
|
||||
"INSERT INTO users (email, password_hash, full_name, role, organization_id, sam_student_id, is_sam_student, sam_verified_at) VALUES ($1, $2, $3, $4, $5, $6, TRUE, NOW()) RETURNING id"
|
||||
)
|
||||
.bind(&sam_student.email)
|
||||
.bind(format!("sam_managed_{}", sam_student.id_alumno))
|
||||
.bind(&sam_student.nombre)
|
||||
.bind("student")
|
||||
.bind(Uuid::parse_str("00000000-0000-0000-0000-000000000001").unwrap())
|
||||
.bind(&sam_student.id_alumno)
|
||||
.fetch_optional(&pool)
|
||||
.await;
|
||||
|
||||
match insert_result {
|
||||
Ok(Some(_)) => students_synced += 1,
|
||||
Ok(None) => errors.push(format!("Failed to create user for {}", sam_student.email)),
|
||||
Err(e) => errors.push(format!("Error creating user {}: {}", sam_student.email, e)),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Sync assignments
|
||||
{
|
||||
let rows = sqlx::query(
|
||||
"SELECT id_contrato, id_alumno, id_curso_abierto, estado FROM sige_sam_v3.detalle_contrato WHERE estado = 'activo' OR estado = 'vigente'"
|
||||
)
|
||||
.fetch_all(&sam_pool)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Failed to fetch assignments: {}", e)))?;
|
||||
|
||||
let sam_assignments: Vec<SamAssignmentInfo> = rows.iter().map(|row| {
|
||||
SamAssignmentInfo {
|
||||
id_contrato: row.get("id_contrato"),
|
||||
id_alumno: row.get("id_alumno"),
|
||||
id_curso_abierto: row.get("id_curso_abierto"),
|
||||
estado: row.get("estado"),
|
||||
}
|
||||
}).collect();
|
||||
|
||||
for assignment in sam_assignments {
|
||||
let course_result = sqlx::query_as::<_, (Uuid,)>(
|
||||
"SELECT id FROM courses WHERE external_sam_id = $1 OR id = $2"
|
||||
)
|
||||
.bind(assignment.id_curso_abierto as i64)
|
||||
.bind(assignment.id_curso_abierto)
|
||||
.fetch_optional(&pool)
|
||||
.await;
|
||||
|
||||
let course_id = match course_result {
|
||||
Ok(Some((id,))) => Some(id),
|
||||
Ok(None) => continue,
|
||||
Err(e) => { errors.push(format!("Error finding course {}: {}", assignment.id_curso_abierto, e)); continue; }
|
||||
};
|
||||
|
||||
if let Some(course_id) = course_id {
|
||||
let upsert_result = sqlx::query(
|
||||
"INSERT INTO sam_course_assignments (sam_student_id, sam_contrato_id, course_id, is_active, synced_at) VALUES ($1, $2, $3, TRUE, NOW()) ON CONFLICT (sam_student_id, course_id) DO UPDATE SET is_active = TRUE, sam_contrato_id = EXCLUDED.sam_contrato_id, synced_at = NOW()"
|
||||
)
|
||||
.bind(&assignment.id_alumno)
|
||||
.bind(&assignment.id_contrato)
|
||||
.bind(course_id)
|
||||
.execute(&pool)
|
||||
.await;
|
||||
|
||||
if upsert_result.is_ok() { assignments_synced += 1; } else { errors.push(format!("Failed to sync assignment for student {}", assignment.id_alumno)); }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
sam_pool.close().await;
|
||||
|
||||
Ok(Json(SamSyncResponse {
|
||||
students_synced,
|
||||
assignments_synced,
|
||||
errors,
|
||||
}))
|
||||
}
|
||||
@@ -11,6 +11,7 @@ mod handlers_test_templates;
|
||||
mod handlers_question_bank;
|
||||
mod handlers_admin;
|
||||
mod handlers_embeddings;
|
||||
mod handlers_sam;
|
||||
mod webhooks;
|
||||
|
||||
use axum::{
|
||||
@@ -97,17 +98,48 @@ async fn main() {
|
||||
});
|
||||
|
||||
// CORS configuration - Allow multiple origins for development and production
|
||||
// Using a predicate closure to support wildcard subdomains for norteamericano.cl
|
||||
use tower_http::cors::AllowOrigin;
|
||||
|
||||
let cors = CorsLayer::new()
|
||||
.allow_origin([
|
||||
"http://localhost:3000".parse::<http::HeaderValue>().unwrap(),
|
||||
"http://localhost:3003".parse::<http::HeaderValue>().unwrap(),
|
||||
"http://127.0.0.1:3000".parse::<http::HeaderValue>().unwrap(),
|
||||
"http://127.0.0.1:3003".parse::<http::HeaderValue>().unwrap(),
|
||||
"http://192.168.0.254:3000".parse::<http::HeaderValue>().unwrap(),
|
||||
"http://192.168.0.254:3003".parse::<http::HeaderValue>().unwrap(),
|
||||
// Allow any origin for development (remove in production)
|
||||
"http://192.168.0.254".parse::<http::HeaderValue>().unwrap(),
|
||||
])
|
||||
.allow_origin(AllowOrigin::predicate(|origin: &http::HeaderValue, _request: &http::request::Parts| -> bool {
|
||||
let origin_str = origin.to_str().unwrap_or("");
|
||||
|
||||
// Development origins
|
||||
let allowed_origins = [
|
||||
"http://localhost:3000",
|
||||
"http://localhost:3003",
|
||||
"http://127.0.0.1:3000",
|
||||
"http://127.0.0.1:3003",
|
||||
"http://192.168.0.254:3000",
|
||||
"http://192.168.0.254:3003",
|
||||
"http://192.168.0.254",
|
||||
// Production - Norteamericano domains (HTTPS)
|
||||
"https://studio.norteamericano.cl",
|
||||
"https://learning.norteamericano.cl",
|
||||
];
|
||||
|
||||
// Check exact matches
|
||||
if allowed_origins.contains(&origin_str) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check wildcard for subdomains: https://*.norteamericano.cl
|
||||
if origin_str.starts_with("https://") && origin_str.ends_with(".norteamericano.cl") {
|
||||
let subdomain = origin_str
|
||||
.strip_prefix("https://")
|
||||
.unwrap_or("")
|
||||
.strip_suffix(".norteamericano.cl")
|
||||
.unwrap_or("");
|
||||
|
||||
// Allow any subdomain (e.g., api., cdn., admin., etc.)
|
||||
if !subdomain.is_empty() && !subdomain.contains('/') {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
false
|
||||
}))
|
||||
.allow_methods([Method::GET, Method::POST, Method::PUT, Method::DELETE, Method::OPTIONS, Method::PATCH, Method::HEAD])
|
||||
.allow_headers([
|
||||
header::CONTENT_TYPE,
|
||||
@@ -392,6 +424,27 @@ async fn main() {
|
||||
"/question-bank/{id}/embedding/regenerate",
|
||||
post(handlers_embeddings::regenerate_question_embedding),
|
||||
)
|
||||
// SAM Integration routes
|
||||
.route(
|
||||
"/sam/sync-all",
|
||||
post(handlers_sam::sync_all_sam),
|
||||
)
|
||||
.route(
|
||||
"/sam/sync-students",
|
||||
post(handlers_sam::sync_sam_students),
|
||||
)
|
||||
.route(
|
||||
"/sam/sync-assignments",
|
||||
post(handlers_sam::sync_sam_assignments),
|
||||
)
|
||||
.route(
|
||||
"/sam/students",
|
||||
get(handlers_sam::list_sam_students),
|
||||
)
|
||||
.route(
|
||||
"/sam/students/{student_id}/courses",
|
||||
get(handlers_sam::get_sam_student_courses),
|
||||
)
|
||||
// Admin routes
|
||||
.route(
|
||||
"/admin/token-usage",
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
-- Migration: Add audio responses table for speaking practice
|
||||
-- Allows students to submit audio recordings and teachers to evaluate them
|
||||
|
||||
-- Table to store student audio responses
|
||||
CREATE TABLE IF NOT EXISTS audio_responses (
|
||||
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,
|
||||
block_id UUID NOT NULL, -- ID del bloque audio-response dentro de la lección
|
||||
prompt TEXT NOT NULL, -- La pregunta/instrucción original
|
||||
transcript TEXT, -- Transcripción del audio (generada por Whisper)
|
||||
audio_url TEXT, -- URL o path del archivo de audio almacenado
|
||||
audio_data BYTEA, -- Audio almacenado en base64 (opcional, para archivos pequeños)
|
||||
|
||||
-- Evaluación de IA
|
||||
ai_score INTEGER, -- Puntaje de IA (0-100)
|
||||
ai_found_keywords TEXT[], -- Keywords encontradas por la IA
|
||||
ai_feedback TEXT, -- Feedback de la IA
|
||||
ai_evaluated_at TIMESTAMPTZ, -- Cuándo fue evaluado por IA
|
||||
|
||||
-- Evaluación del profesor
|
||||
teacher_score INTEGER, -- Puntaje del profesor (0-100)
|
||||
teacher_feedback TEXT, -- Feedback del profesor
|
||||
teacher_evaluated_at TIMESTAMPTZ, -- Cuándo fue evaluado por el profesor
|
||||
teacher_evaluated_by UUID, -- ID del profesor que evaluó
|
||||
|
||||
-- Estado y metadatos
|
||||
status VARCHAR(50) NOT NULL DEFAULT 'pending', -- 'pending', 'ai_evaluated', 'teacher_evaluated', 'both_evaluated'
|
||||
attempt_number INTEGER NOT NULL DEFAULT 1, -- Número de intento (permite reintentos)
|
||||
duration_seconds INTEGER, -- Duración de la grabación en segundos
|
||||
metadata JSONB, -- Metadatos adicionales (calidad de audio, etc.)
|
||||
|
||||
-- Timestamps
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
|
||||
-- Foreign keys
|
||||
CONSTRAINT fk_audio_response_organization FOREIGN KEY (organization_id) REFERENCES organizations(id) ON DELETE CASCADE,
|
||||
CONSTRAINT fk_audio_response_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
|
||||
CONSTRAINT fk_audio_response_course FOREIGN KEY (course_id) REFERENCES courses(id) ON DELETE CASCADE,
|
||||
CONSTRAINT fk_audio_response_lesson FOREIGN KEY (lesson_id) REFERENCES lessons(id) ON DELETE CASCADE,
|
||||
CONSTRAINT fk_audio_response_teacher FOREIGN KEY (teacher_evaluated_by) REFERENCES users(id) ON DELETE SET NULL,
|
||||
|
||||
-- Constraints
|
||||
CONSTRAINT chk_ai_score CHECK (ai_score IS NULL OR (ai_score >= 0 AND ai_score <= 100)),
|
||||
CONSTRAINT chk_teacher_score CHECK (teacher_score IS NULL OR (teacher_score >= 0 AND teacher_score <= 100)),
|
||||
CONSTRAINT chk_attempt_number CHECK (attempt_number >= 1)
|
||||
);
|
||||
|
||||
-- Indexes for performance
|
||||
CREATE INDEX idx_audio_responses_user_id ON audio_responses(user_id);
|
||||
CREATE INDEX idx_audio_responses_course_id ON audio_responses(course_id);
|
||||
CREATE INDEX idx_audio_responses_lesson_id ON audio_responses(lesson_id);
|
||||
CREATE INDEX idx_audio_responses_block_id ON audio_responses(block_id);
|
||||
CREATE INDEX idx_audio_responses_organization_id ON audio_responses(organization_id);
|
||||
CREATE INDEX idx_audio_responses_status ON audio_responses(status);
|
||||
CREATE INDEX idx_audio_responses_created_at ON audio_responses(created_at DESC);
|
||||
CREATE INDEX idx_audio_responses_teacher_evaluated ON audio_responses(teacher_evaluated_at) WHERE teacher_evaluated_at IS NOT NULL;
|
||||
|
||||
-- Composite index for common queries
|
||||
CREATE INDEX idx_audio_responses_user_lesson_block ON audio_responses(user_id, lesson_id, block_id);
|
||||
|
||||
-- Add comment
|
||||
COMMENT ON TABLE audio_responses IS 'Almacena las respuestas de audio de los estudiantes para ejercicios de speaking practice';
|
||||
COMMENT ON COLUMN audio_responses.status IS 'pending: sin evaluar, ai_evaluated: solo IA, teacher_evaluated: solo profesor, both_evaluated: ambos';
|
||||
|
||||
-- Add updated_at trigger
|
||||
CREATE OR REPLACE FUNCTION update_audio_responses_updated_at()
|
||||
RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
NEW.updated_at = NOW();
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
CREATE TRIGGER trg_audio_responses_updated_at
|
||||
BEFORE UPDATE ON audio_responses
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION update_audio_responses_updated_at();
|
||||
|
||||
-- View para profesores: respuestas pendientes de evaluación
|
||||
CREATE VIEW v_pending_audio_responses AS
|
||||
SELECT
|
||||
ar.id,
|
||||
ar.user_id,
|
||||
u.full_name AS student_name,
|
||||
u.email AS student_email,
|
||||
ar.course_id,
|
||||
c.title AS course_title,
|
||||
ar.lesson_id,
|
||||
l.title AS lesson_title,
|
||||
ar.block_id,
|
||||
ar.prompt,
|
||||
ar.transcript,
|
||||
ar.ai_score,
|
||||
ar.ai_feedback,
|
||||
ar.status,
|
||||
ar.created_at,
|
||||
ar.attempt_number
|
||||
FROM audio_responses ar
|
||||
JOIN users u ON ar.user_id = u.id
|
||||
JOIN courses c ON ar.course_id = c.id
|
||||
JOIN lessons l ON ar.lesson_id = l.id
|
||||
WHERE ar.teacher_evaluated_at IS NULL
|
||||
ORDER BY ar.created_at DESC;
|
||||
|
||||
-- View para estadísticas de evaluación
|
||||
CREATE VIEW v_audio_response_stats AS
|
||||
SELECT
|
||||
organization_id,
|
||||
course_id,
|
||||
lesson_id,
|
||||
COUNT(*) AS total_responses,
|
||||
COUNT(*) FILTER (WHERE ai_score IS NOT NULL) AS ai_evaluated,
|
||||
COUNT(*) FILTER (WHERE teacher_score IS NOT NULL) AS teacher_evaluated,
|
||||
COUNT(*) FILTER (WHERE status = 'both_evaluated') AS fully_evaluated,
|
||||
COUNT(*) FILTER (WHERE status = 'pending') AS pending,
|
||||
AVG(ai_score) FILTER (WHERE ai_score IS NOT NULL) AS avg_ai_score,
|
||||
AVG(teacher_score) FILTER (WHERE teacher_score IS NOT NULL) AS avg_teacher_score
|
||||
FROM audio_responses
|
||||
GROUP BY organization_id, course_id, lesson_id;
|
||||
@@ -0,0 +1,8 @@
|
||||
-- Migration: Add audio_response_status ENUM type
|
||||
-- This migration creates the ENUM type for audio response status
|
||||
|
||||
DO $$ BEGIN
|
||||
CREATE TYPE audio_response_status AS ENUM ('pending', 'ai_evaluated', 'teacher_evaluated', 'both_evaluated');
|
||||
EXCEPTION
|
||||
WHEN duplicate_object THEN null;
|
||||
END $$;
|
||||
@@ -6,6 +6,7 @@ use axum::{
|
||||
Extension,
|
||||
};
|
||||
use bcrypt::{DEFAULT_COST, hash, verify};
|
||||
use chrono::{DateTime, Utc};
|
||||
use common::auth::{Claims, create_jwt};
|
||||
use common::middleware::Org;
|
||||
use common::models::{
|
||||
@@ -15,6 +16,7 @@ use common::models::{
|
||||
};
|
||||
use crate::external_db::MySqlPool;
|
||||
use serde_json::json;
|
||||
use base64::Engine;
|
||||
|
||||
// Simple token counter (approximate: 1 token ≈ 4 characters in English, ~3-5 in Spanish)
|
||||
fn count_tokens(text: &str) -> i32 {
|
||||
@@ -2103,6 +2105,7 @@ pub async fn get_recommendations(
|
||||
pub async fn evaluate_audio_response(
|
||||
Org(_org_ctx): Org,
|
||||
claims: Claims,
|
||||
State(pool): State<PgPool>,
|
||||
Json(payload): Json<AudioGradingPayload>,
|
||||
) -> Result<Json<AudioGradingResponse>, (StatusCode, String)> {
|
||||
// Check token limit before proceeding (estimate 1500 tokens for audio evaluation)
|
||||
@@ -2182,14 +2185,20 @@ pub async fn evaluate_audio_response(
|
||||
}
|
||||
|
||||
pub async fn evaluate_audio_file(
|
||||
Org(_org_ctx): Org,
|
||||
_claims: Claims,
|
||||
Org(org_ctx): Org,
|
||||
claims: Claims,
|
||||
State(pool): State<PgPool>,
|
||||
mut multipart: Multipart,
|
||||
) -> Result<Json<AudioGradingResponse>, (StatusCode, String)> {
|
||||
let mut lesson_id_str = String::new();
|
||||
let mut block_id_str = String::new();
|
||||
let mut prompt = String::new();
|
||||
let mut keywords_str = String::new();
|
||||
let mut audio_data = Vec::new();
|
||||
let mut filename = "audio.webm".to_string();
|
||||
let mut duration_seconds: Option<i32> = None;
|
||||
|
||||
tracing::info!("Received audio evaluation request from user: {}", claims.sub);
|
||||
|
||||
while let Some(field) = multipart
|
||||
.next_field()
|
||||
@@ -2198,8 +2207,24 @@ pub async fn evaluate_audio_file(
|
||||
{
|
||||
let name = field.name().unwrap_or_default().to_string();
|
||||
match name.as_str() {
|
||||
"prompt" => prompt = field.text().await.unwrap_or_default(),
|
||||
"lesson_id" => {
|
||||
lesson_id_str = field.text().await.unwrap_or_default();
|
||||
tracing::info!("Received lesson_id: {}", lesson_id_str);
|
||||
}
|
||||
"block_id" => {
|
||||
block_id_str = field.text().await.unwrap_or_default();
|
||||
tracing::info!("Received block_id: {}", block_id_str);
|
||||
}
|
||||
"prompt" => {
|
||||
prompt = field.text().await.unwrap_or_default();
|
||||
tracing::info!("Received prompt: {}", prompt);
|
||||
}
|
||||
"keywords" => keywords_str = field.text().await.unwrap_or_default(),
|
||||
"duration" => {
|
||||
if let Ok(d) = field.text().await.unwrap_or_default().parse() {
|
||||
duration_seconds = Some(d);
|
||||
}
|
||||
}
|
||||
"file" => {
|
||||
filename = field.file_name().unwrap_or("audio.webm").to_string();
|
||||
audio_data = field
|
||||
@@ -2207,18 +2232,35 @@ pub async fn evaluate_audio_file(
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
|
||||
.to_vec();
|
||||
tracing::info!("Received audio file: {} bytes", audio_data.len());
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
if audio_data.is_empty() {
|
||||
tracing::error!("No audio data received");
|
||||
return Err((
|
||||
StatusCode::BAD_REQUEST,
|
||||
"No se proporcionó ningún archivo de audio".into(),
|
||||
));
|
||||
}
|
||||
|
||||
// Parse lesson_id and block_id
|
||||
let lesson_id = Uuid::parse_str(&lesson_id_str)
|
||||
.map_err(|_| (StatusCode::BAD_REQUEST, "Invalid lesson_id".into()))?;
|
||||
let block_id = Uuid::parse_str(&block_id_str)
|
||||
.map_err(|_| (StatusCode::BAD_REQUEST, "Invalid block_id".into()))?;
|
||||
|
||||
// Get course_id from lesson (lessons has module_id, modules has course_id)
|
||||
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, "Lesson not found".into()))?;
|
||||
|
||||
// 1. Send to Whisper
|
||||
let whisper_url =
|
||||
env::var("LOCAL_WHISPER_URL").unwrap_or_else(|_| "http://localhost:8000".to_string());
|
||||
@@ -2227,7 +2269,7 @@ pub async fn evaluate_audio_file(
|
||||
let form = reqwest::multipart::Form::new()
|
||||
.part(
|
||||
"file",
|
||||
reqwest::multipart::Part::bytes(audio_data).file_name(filename),
|
||||
reqwest::multipart::Part::bytes(audio_data.clone()).file_name(filename),
|
||||
)
|
||||
.text("model", "whisper-1")
|
||||
.text("response_format", "json");
|
||||
@@ -2355,9 +2397,372 @@ pub async fn evaluate_audio_file(
|
||||
})
|
||||
).map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Mapping failed: {}", e)))?;
|
||||
|
||||
// 3. Save audio response to database
|
||||
// Determine status based on evaluation
|
||||
let status = "ai_evaluated";
|
||||
|
||||
// Get attempt number (check if there's a previous response for this block)
|
||||
let attempt_number: i32 = sqlx::query_scalar(
|
||||
"SELECT COALESCE(MAX(attempt_number), 0) + 1 FROM audio_responses WHERE user_id = $1 AND lesson_id = $2 AND block_id = $3"
|
||||
)
|
||||
.bind(claims.sub)
|
||||
.bind(lesson_id)
|
||||
.bind(block_id)
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.unwrap_or(1);
|
||||
|
||||
// Store audio as base64 for now (can be moved to object storage later)
|
||||
let audio_base64 = base64::engine::general_purpose::STANDARD.encode(&audio_data);
|
||||
|
||||
let _ = sqlx::query(
|
||||
r#"INSERT INTO audio_responses
|
||||
(organization_id, user_id, course_id, lesson_id, block_id, prompt, transcript, audio_data,
|
||||
ai_score, ai_found_keywords, ai_feedback, ai_evaluated_at,
|
||||
status, attempt_number, duration_seconds)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, NOW(), $12, $13, $14)"#
|
||||
)
|
||||
.bind(org_ctx.id)
|
||||
.bind(claims.sub)
|
||||
.bind(course_id)
|
||||
.bind(lesson_id)
|
||||
.bind(block_id)
|
||||
.bind(&prompt)
|
||||
.bind(&transcript)
|
||||
.bind(&audio_base64)
|
||||
.bind(grading.score)
|
||||
.bind(&grading.found_keywords)
|
||||
.bind(&grading.feedback)
|
||||
.bind(status)
|
||||
.bind(attempt_number)
|
||||
.bind(duration_seconds)
|
||||
.execute(&pool)
|
||||
.await;
|
||||
|
||||
Ok(Json(grading))
|
||||
}
|
||||
|
||||
// ==================== AUDIO RESPONSE TEACHER ENDPOINTS ====================
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, sqlx::FromRow)]
|
||||
pub struct AudioResponseListItem {
|
||||
pub id: Uuid,
|
||||
pub user_id: Uuid,
|
||||
pub student_name: String,
|
||||
pub student_email: String,
|
||||
pub course_id: Uuid,
|
||||
pub course_title: String,
|
||||
pub lesson_id: Uuid,
|
||||
pub lesson_title: String,
|
||||
pub block_id: Uuid,
|
||||
pub prompt: String,
|
||||
pub transcript: Option<String>,
|
||||
pub ai_score: Option<i32>,
|
||||
pub ai_found_keywords: Option<Vec<String>>,
|
||||
pub ai_feedback: Option<String>,
|
||||
pub teacher_score: Option<i32>,
|
||||
pub teacher_feedback: Option<String>,
|
||||
pub status: String,
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub attempt_number: i32,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct AudioResponseFilters {
|
||||
pub course_id: Option<Uuid>,
|
||||
pub lesson_id: Option<Uuid>,
|
||||
pub status: Option<String>,
|
||||
pub user_id: Option<Uuid>,
|
||||
}
|
||||
|
||||
/// Get all audio responses for teachers
|
||||
/// Filters: course_id, lesson_id, status (pending, ai_evaluated, teacher_evaluated, both_evaluated), user_id
|
||||
pub async fn get_audio_responses(
|
||||
Org(org_ctx): Org,
|
||||
claims: Claims,
|
||||
State(pool): State<PgPool>,
|
||||
Query(filters): Query<AudioResponseFilters>,
|
||||
) -> Result<Json<Vec<AudioResponseListItem>>, StatusCode> {
|
||||
// Only instructors and admins can access
|
||||
if claims.role != "admin" && claims.role != "instructor" {
|
||||
return Err(StatusCode::FORBIDDEN);
|
||||
}
|
||||
|
||||
// Use static query with optional filters
|
||||
let responses = sqlx::query_as::<_, AudioResponseListItem>(
|
||||
r#"
|
||||
SELECT
|
||||
ar.id,
|
||||
ar.user_id,
|
||||
u.full_name as student_name,
|
||||
u.email as student_email,
|
||||
ar.course_id,
|
||||
c.title as course_title,
|
||||
ar.lesson_id,
|
||||
l.title as lesson_title,
|
||||
ar.block_id,
|
||||
ar.prompt,
|
||||
ar.transcript,
|
||||
ar.ai_score,
|
||||
ar.ai_found_keywords,
|
||||
ar.ai_feedback,
|
||||
ar.teacher_score,
|
||||
ar.teacher_feedback,
|
||||
ar.status::text,
|
||||
ar.created_at,
|
||||
ar.attempt_number
|
||||
FROM audio_responses ar
|
||||
JOIN users u ON ar.user_id = u.id
|
||||
JOIN courses c ON ar.course_id = c.id
|
||||
JOIN lessons l ON ar.lesson_id = l.id
|
||||
WHERE ar.organization_id = $1
|
||||
AND ($2::uuid IS NULL OR ar.course_id = $2)
|
||||
AND ($3::uuid IS NULL OR ar.lesson_id = $3)
|
||||
AND ($4::text IS NULL OR ar.status::text = $4)
|
||||
AND ($5::uuid IS NULL OR ar.user_id = $5)
|
||||
ORDER BY ar.created_at DESC
|
||||
"#
|
||||
)
|
||||
.bind(org_ctx.id)
|
||||
.bind(filters.course_id)
|
||||
.bind(filters.lesson_id)
|
||||
.bind(filters.status)
|
||||
.bind(filters.user_id)
|
||||
.fetch_all(&pool)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
tracing::error!("Error fetching audio responses: {}", e);
|
||||
StatusCode::INTERNAL_SERVER_ERROR
|
||||
})?;
|
||||
|
||||
Ok(Json(responses))
|
||||
}
|
||||
|
||||
/// Get single audio response with full details including audio data
|
||||
pub async fn get_audio_response_detail(
|
||||
Org(org_ctx): Org,
|
||||
claims: Claims,
|
||||
State(pool): State<PgPool>,
|
||||
Path(response_id): Path<Uuid>,
|
||||
) -> Result<Json<AudioResponseListItem>, StatusCode> {
|
||||
// Only instructors and admins can access
|
||||
if claims.role != "admin" && claims.role != "instructor" {
|
||||
return Err(StatusCode::FORBIDDEN);
|
||||
}
|
||||
|
||||
let response = sqlx::query_as::<_, AudioResponseListItem>(
|
||||
r#"
|
||||
SELECT
|
||||
ar.id,
|
||||
ar.user_id,
|
||||
u.full_name as student_name,
|
||||
u.email as student_email,
|
||||
ar.course_id,
|
||||
c.title as course_title,
|
||||
ar.lesson_id,
|
||||
l.title as lesson_title,
|
||||
ar.block_id,
|
||||
ar.prompt,
|
||||
ar.transcript,
|
||||
ar.ai_score,
|
||||
ar.ai_found_keywords,
|
||||
ar.ai_feedback,
|
||||
ar.teacher_score,
|
||||
ar.teacher_feedback,
|
||||
ar.status::text,
|
||||
ar.created_at,
|
||||
ar.attempt_number
|
||||
FROM audio_responses ar
|
||||
JOIN users u ON ar.user_id = u.id
|
||||
JOIN courses c ON ar.course_id = c.id
|
||||
JOIN lessons l ON ar.lesson_id = l.id
|
||||
WHERE ar.id = $1 AND ar.organization_id = $2
|
||||
"#
|
||||
)
|
||||
.bind(response_id)
|
||||
.bind(org_ctx.id)
|
||||
.fetch_optional(&pool)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
tracing::error!("Error fetching audio response: {}", e);
|
||||
StatusCode::INTERNAL_SERVER_ERROR
|
||||
})?;
|
||||
|
||||
match response {
|
||||
Some(r) => Ok(Json(r)),
|
||||
None => Err(StatusCode::NOT_FOUND),
|
||||
}
|
||||
}
|
||||
|
||||
/// Get audio data as base64 for playback
|
||||
pub async fn get_audio_response_audio(
|
||||
Org(org_ctx): Org,
|
||||
_claims: Claims,
|
||||
State(pool): State<PgPool>,
|
||||
Path(response_id): Path<Uuid>,
|
||||
) -> Result<impl IntoResponse, StatusCode> {
|
||||
// Only instructors, admins, and the owner can access
|
||||
let audio_data: Option<Vec<u8>> = sqlx::query_scalar(
|
||||
"SELECT audio_data FROM audio_responses WHERE id = $1 AND organization_id = $2"
|
||||
)
|
||||
.bind(response_id)
|
||||
.bind(org_ctx.id)
|
||||
.fetch_optional(&pool)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
tracing::error!("Error fetching audio data: {}", e);
|
||||
StatusCode::INTERNAL_SERVER_ERROR
|
||||
})?;
|
||||
|
||||
match audio_data {
|
||||
Some(data) => {
|
||||
// Decode from base64
|
||||
let audio_bytes = base64::engine::general_purpose::STANDARD.decode(&data)
|
||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||
|
||||
Ok(axum::response::Response::builder()
|
||||
.header(axum::http::header::CONTENT_TYPE, "audio/webm")
|
||||
.header(axum::http::header::CONTENT_DISPOSITION, "inline")
|
||||
.body(axum::body::Body::from(audio_bytes))
|
||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?
|
||||
.into_response())
|
||||
}
|
||||
None => Err(StatusCode::NOT_FOUND),
|
||||
}
|
||||
}
|
||||
|
||||
/// Teacher evaluates an audio response
|
||||
pub async fn teacher_evaluate_audio(
|
||||
Org(org_ctx): Org,
|
||||
claims: Claims,
|
||||
State(pool): State<PgPool>,
|
||||
Path(response_id): Path<Uuid>,
|
||||
Json(payload): Json<common::models::UpdateAudioResponsePayload>,
|
||||
) -> Result<Json<serde_json::Value>, StatusCode> {
|
||||
// Only instructors and admins can evaluate
|
||||
if claims.role != "admin" && claims.role != "instructor" {
|
||||
return Err(StatusCode::FORBIDDEN);
|
||||
}
|
||||
|
||||
// Validate score
|
||||
if payload.teacher_score < 0 || payload.teacher_score > 100 {
|
||||
return Err(StatusCode::BAD_REQUEST);
|
||||
}
|
||||
|
||||
// Get current response to determine new status
|
||||
let current_status: String = sqlx::query_scalar(
|
||||
"SELECT status::text FROM audio_responses WHERE id = $1 AND organization_id = $2"
|
||||
)
|
||||
.bind(response_id)
|
||||
.bind(org_ctx.id)
|
||||
.fetch_optional(&pool)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
tracing::error!("Error fetching audio response: {}", e);
|
||||
StatusCode::INTERNAL_SERVER_ERROR
|
||||
})?
|
||||
.unwrap_or_else(|| "pending".to_string());
|
||||
|
||||
// Determine new status
|
||||
let new_status = if current_status == "ai_evaluated" {
|
||||
"both_evaluated"
|
||||
} else {
|
||||
"teacher_evaluated"
|
||||
};
|
||||
|
||||
// Update the response
|
||||
let updated = sqlx::query(
|
||||
r#"
|
||||
UPDATE audio_responses
|
||||
SET
|
||||
teacher_score = $1,
|
||||
teacher_feedback = $2,
|
||||
teacher_evaluated_at = NOW(),
|
||||
teacher_evaluated_by = $3,
|
||||
status = $4,
|
||||
updated_at = NOW()
|
||||
WHERE id = $5 AND organization_id = $6
|
||||
RETURNING id
|
||||
"#
|
||||
)
|
||||
.bind(payload.teacher_score)
|
||||
.bind(&payload.teacher_feedback)
|
||||
.bind(claims.sub)
|
||||
.bind(new_status)
|
||||
.bind(response_id)
|
||||
.bind(org_ctx.id)
|
||||
.fetch_optional(&pool)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
tracing::error!("Error updating audio response: {}", e);
|
||||
StatusCode::INTERNAL_SERVER_ERROR
|
||||
})?;
|
||||
|
||||
match updated {
|
||||
Some(_) => Ok(Json(json!({
|
||||
"success": true,
|
||||
"message": "Evaluación guardada exitosamente"
|
||||
}))),
|
||||
None => Err(StatusCode::NOT_FOUND),
|
||||
}
|
||||
}
|
||||
|
||||
/// Get audio response statistics for a course
|
||||
pub async fn get_audio_response_stats(
|
||||
Org(org_ctx): Org,
|
||||
claims: Claims,
|
||||
State(pool): State<PgPool>,
|
||||
Path(course_id): Path<Uuid>,
|
||||
) -> Result<Json<common::models::AudioResponseStats>, StatusCode> {
|
||||
// Only instructors and admins can access
|
||||
if claims.role != "admin" && claims.role != "instructor" {
|
||||
return Err(StatusCode::FORBIDDEN);
|
||||
}
|
||||
|
||||
let stats = sqlx::query_as::<_, common::models::AudioResponseStats>(
|
||||
r#"
|
||||
SELECT
|
||||
organization_id,
|
||||
course_id,
|
||||
lesson_id,
|
||||
COUNT(*) as total_responses,
|
||||
COUNT(*) FILTER (WHERE ai_score IS NOT NULL) as ai_evaluated,
|
||||
COUNT(*) FILTER (WHERE teacher_score IS NOT NULL) as teacher_evaluated,
|
||||
COUNT(*) FILTER (WHERE status = 'both_evaluated') as fully_evaluated,
|
||||
COUNT(*) FILTER (WHERE status = 'pending') as pending,
|
||||
AVG(ai_score) FILTER (WHERE ai_score IS NOT NULL) as avg_ai_score,
|
||||
AVG(teacher_score) FILTER (WHERE teacher_score IS NOT NULL) as avg_teacher_score
|
||||
FROM audio_responses
|
||||
WHERE course_id = $1 AND organization_id = $2
|
||||
GROUP BY organization_id, course_id, lesson_id
|
||||
"#
|
||||
)
|
||||
.bind(course_id)
|
||||
.bind(org_ctx.id)
|
||||
.fetch_optional(&pool)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
tracing::error!("Error fetching audio response stats: {}", e);
|
||||
StatusCode::INTERNAL_SERVER_ERROR
|
||||
})?;
|
||||
|
||||
match stats {
|
||||
Some(s) => Ok(Json(s)),
|
||||
None => Ok(Json(common::models::AudioResponseStats {
|
||||
organization_id: org_ctx.id,
|
||||
course_id,
|
||||
lesson_id: Uuid::nil(),
|
||||
total_responses: 0,
|
||||
ai_evaluated: 0,
|
||||
teacher_evaluated: 0,
|
||||
fully_evaluated: 0,
|
||||
pending: 0,
|
||||
avg_ai_score: None,
|
||||
avg_teacher_score: None,
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct ChatPayload {
|
||||
pub message: String,
|
||||
|
||||
@@ -66,17 +66,48 @@ async fn main() {
|
||||
});
|
||||
|
||||
// CORS configuration - Allow multiple origins for development and production
|
||||
// Using a predicate closure to support wildcard subdomains for norteamericano.cl
|
||||
use tower_http::cors::AllowOrigin;
|
||||
|
||||
let cors = CorsLayer::new()
|
||||
.allow_origin([
|
||||
"http://localhost:3000".parse::<http::HeaderValue>().unwrap(),
|
||||
"http://localhost:3003".parse::<http::HeaderValue>().unwrap(),
|
||||
"http://127.0.0.1:3000".parse::<http::HeaderValue>().unwrap(),
|
||||
"http://127.0.0.1:3003".parse::<http::HeaderValue>().unwrap(),
|
||||
"http://192.168.0.254:3000".parse::<http::HeaderValue>().unwrap(),
|
||||
"http://192.168.0.254:3003".parse::<http::HeaderValue>().unwrap(),
|
||||
// Allow any origin for development (remove in production)
|
||||
"http://192.168.0.254".parse::<http::HeaderValue>().unwrap(),
|
||||
])
|
||||
.allow_origin(AllowOrigin::predicate(|origin: &http::HeaderValue, _request: &http::request::Parts| -> bool {
|
||||
let origin_str = origin.to_str().unwrap_or("");
|
||||
|
||||
// Development origins
|
||||
let allowed_origins = [
|
||||
"http://localhost:3000",
|
||||
"http://localhost:3003",
|
||||
"http://127.0.0.1:3000",
|
||||
"http://127.0.0.1:3003",
|
||||
"http://192.168.0.254:3000",
|
||||
"http://192.168.0.254:3003",
|
||||
"http://192.168.0.254",
|
||||
// Production - Norteamericano domains (HTTPS)
|
||||
"https://studio.norteamericano.cl",
|
||||
"https://learning.norteamericano.cl",
|
||||
];
|
||||
|
||||
// Check exact matches
|
||||
if allowed_origins.contains(&origin_str) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check wildcard for subdomains: https://*.norteamericano.cl
|
||||
if origin_str.starts_with("https://") && origin_str.ends_with(".norteamericano.cl") {
|
||||
let subdomain = origin_str
|
||||
.strip_prefix("https://")
|
||||
.unwrap_or("")
|
||||
.strip_suffix(".norteamericano.cl")
|
||||
.unwrap_or("");
|
||||
|
||||
// Allow any subdomain (e.g., api., cdn., admin., etc.)
|
||||
if !subdomain.is_empty() && !subdomain.contains('/') {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
false
|
||||
}))
|
||||
.allow_methods([Method::GET, Method::POST, Method::PUT, Method::DELETE, Method::OPTIONS, Method::PATCH])
|
||||
.allow_headers([
|
||||
header::CONTENT_TYPE,
|
||||
@@ -158,6 +189,12 @@ async fn main() {
|
||||
.route("/lessons/{id}/heatmap", get(handlers::get_lesson_heatmap))
|
||||
.route("/audio/evaluate", post(handlers::evaluate_audio_response))
|
||||
.route("/audio/evaluate-file", post(handlers::evaluate_audio_file))
|
||||
// Audio Response Teacher Routes
|
||||
.route("/audio-responses", get(handlers::get_audio_responses))
|
||||
.route("/audio-responses/{id}", get(handlers::get_audio_response_detail))
|
||||
.route("/audio-responses/{id}/audio", get(handlers::get_audio_response_audio))
|
||||
.route("/audio-responses/{id}/evaluate", post(handlers::teacher_evaluate_audio))
|
||||
.route("/courses/{id}/audio-responses/stats", get(handlers::get_audio_response_stats))
|
||||
.route("/lessons/{id}/chat", post(handlers::chat_with_tutor))
|
||||
.route("/lessons/{id}/chat-role-play", post(handlers::chat_role_play))
|
||||
.route("/lessons/{id}/code-hint", post(handlers::get_code_hint))
|
||||
|
||||
Reference in New Issue
Block a user