diff --git a/docs/OpenCCB_Manual_Studio.docx b/docs/OpenCCB_Manual_Studio.docx new file mode 100644 index 0000000..a37c026 Binary files /dev/null and b/docs/OpenCCB_Manual_Studio.docx differ diff --git a/docs/OpenCCB_Manual_Studio.md b/docs/OpenCCB_Manual_Studio.md new file mode 100644 index 0000000..c6035aa --- /dev/null +++ b/docs/OpenCCB_Manual_Studio.md @@ -0,0 +1,118 @@ +# Manual de Usuario - OpenCCB Studio +*Versión 1.0* + +Bienvenido al manual de uso de **OpenCCB Studio**, el entorno de administración integral para gestionar el portal educativo. Aquí aprenderás a ingresar, navegar el panel de administración y gestionar las actividades. + +--- + +## 1. Inicio de Sesión + +Para acceder a OpenCCB Studio: +1. Abra su explorador web (se recomienda Google Chrome, Firefox o Edge). +2. Diríjase a la dirección URL de Studio (por ejemplo: `http://localhost:3000`). +3. En la pantalla de inicio, ingrese sus credenciales (**Email** y **Contraseña**). +4. Haga clic en **"Iniciar Sesión"**. + +> [!NOTE] +> *[📸 INSERTA AQUÍ LA CAPTURA DE PANTALLA: Pantalla de Login]* +> *Nombre sugerido de la imagen para la ruta: `/home/juan/dev/openccb/docs/img/login.png`* +> ![Pantalla de Login](./img/login.png) + +--- + +## 2. Panel de Administrador (Dashboard) + +Una vez iniciada la sesión, será redirigido al **Dashboard** o Panel de Control. +Este panel sirve como el corazón operativo de su plataforma: + +* **Menú Lateral:** En la parte izquierda, encontrará la navegación para acceder a Usuarios, Cursos, Organizaciones, Banco de Preguntas, y el Creador de Ejercicios. +* **Resumen General:** La pantalla principal mostrará estadísticas rápidas de uso (total de alumnos inscritos, cursos activos, etc.). + +> [!NOTE] +> *[📸 INSERTA AQUÍ LA CAPTURA DE PANTALLA: Dashboard Principal]* +> *Nombre sugerido de la imagen: `/home/juan/dev/openccb/docs/img/dashboard.png`* +> ![Dashboard](./img/dashboard.png) + +--- + +## 3. Tipos de Ejercicios y su Creación + +OpenCCB Studio permite una gestión altamente detallada del contenido. A continuación se explican los distintos tipos de ejercicios que se pueden construir en la plataforma. + +Para crear cualquiera de ellos, diríjase a **"Banco de Preguntas"** o **"Actividades"** en el menú lateral y presione botón **"Crear Nueva"**. + +### 3.1 Opción Múltiple (Multiple Choice) +Ejercicio estándar donde el estudiante debe elegir una única respuesta correcta (o varias, dependiendo de la configuración) de una lista de alternativas. + +**Pasos para crearlo:** +1. Seleccione "Opción Múltiple" como Tipo de Ejercicio. +2. Complete el **Enunciado** de la pregunta. +3. Añada las alternativas haciendo clic en "Agregar Opción". +4. Marque con el selector ("Correct") la(s) opción(es) que es o son correctas. +5. Seleccione Guardar. + +> [!NOTE] +> *[📸 INSERTA AQUÍ LA CAPTURA DE PANTALLA: Formulario de creación de "Multiple Choice"]* +> ![Multiple Choice](./img/ejercicio_multiple_choice.png) + +### 3.2 Verdadero / Falso (True/False) +Este ejercicio evalúa la veracidad de una afirmación o concepto. + +**Pasos para crearlo:** +1. Seleccione "Verdadero o Falso". +2. Redacte la afirmación de la pregunta. +3. Marque la alternativa correcta, o bien "Verdadero", o bien "Falso". +4. Guarde los cambios. + +> [!NOTE] +> *[📸 INSERTA AQUÍ LA CAPTURA DE PANTALLA: Formulario "Verdadero o Falso"]* +> ![Verdadero Falso](./img/ejercicio_tf.png) + +### 3.3 Completar Espacios (Fill in the Blanks) +Este tipo de ejercicio es ideal para pruebas de lenguaje y gramática. El estudiante debe tippear la palabra exacta. + +**Pasos para crearlo:** +1. Seleccione "Completar Espacio". +2. Escriba el texto principal e inserte marcadores donde desee que queden los huecos (por ejemplo: `___` o usando el formato del editor). +3. En el panel de respuestas, declare la palabra correcta para cada marcador. +4. Puede establecer opciones sensibles a mayúsculas/minúsculas si el módulo de IA lo avala. + +> [!NOTE] +> *[📸 INSERTA AQUÍ LA CAPTURA DE PANTALLA: Configuracion Fill-in-the-blanks]* +> ![Fill in blanks](./img/ejercicio_fill_blanks.png) + +### 3.4 Arrastrar y Soltar (Drag & Drop) +Ejercicios interactivos donde el usuario asocia conceptos uniendo elementos. + +**Pasos para crearlo:** +1. En configuración, defina los "Contenedores" o zonas destino (Targets). +2. Defina los "Elementos a arrastrar" (Items). +3. Establezca las relaciones lógicas indicando qué "Elemento" pertenece a qué "Contenedor" en la sección de respuestas (Mapping). + +> [!NOTE] +> *[📸 INSERTA AQUÍ LA CAPTURA DE PANTALLA: Ejercicio Drag and Drop]* +> ![Drag and Drop](./img/ejercicio_drag_drop.png) + +### 3.5 Respuesta Corta Abierta (Open Ended / AI Graded) +Con la funcionalidad de Inteligencia Artificial (LLM) de OpenCCB, es posible hacer preguntas de desarrollo donde el alumno escribe su respuesta y un modelo la califica bajo ciertos parámetros de rúbrica. + +1. Seleccione "Respuesta Abierta". +2. Asigne la **Rúbrica de corrección** (ej. "La respuesta debe mencionar las causas de la Revolución Francesa. 0 puntaje si menciona factores incorrectos"). +3. Determine el número máximo de palabras o la extensión requerida. +4. Elija si desea habilitar Voice-to-Text (Whisper) para responder usando el micrófono. + +> [!NOTE] +> *[📸 INSERTA AQUÍ LA CAPTURA DE PANTALLA: Ejercicio Respuesta Abierta / VoiceToText]* +> ![Open Ended](./img/ejercicio_open_ended.png) + +--- + +## 4. Exportar a PDF desde este Documento +Como los prerrequisitos se instalaron usando el script `install.sh`, en su entorno puede ubicar la consola y ejecutar este comando dentro del directorio que contiene este archivo (`docs`): + +```bash +# Convertir el manual Markdown a PDF +pandoc OpenCCB_Manual_Studio.md -o OpenCCB_Manual_Studio.pdf --pdf-engine=xelatex +``` + +*(Recuerde colocar las correspondientes imágenes simuladas o tomar las capturas desde su Studio antes en la carpeta `img`)* diff --git a/docs/img/dashboard.png b/docs/img/dashboard.png new file mode 100644 index 0000000..952b532 Binary files /dev/null and b/docs/img/dashboard.png differ diff --git a/docs/img/ejercicio_drag_drop.png b/docs/img/ejercicio_drag_drop.png new file mode 100644 index 0000000..952b532 Binary files /dev/null and b/docs/img/ejercicio_drag_drop.png differ diff --git a/docs/img/ejercicio_fill_blanks.png b/docs/img/ejercicio_fill_blanks.png new file mode 100644 index 0000000..952b532 Binary files /dev/null and b/docs/img/ejercicio_fill_blanks.png differ diff --git a/docs/img/ejercicio_multiple_choice.png b/docs/img/ejercicio_multiple_choice.png new file mode 100644 index 0000000..952b532 Binary files /dev/null and b/docs/img/ejercicio_multiple_choice.png differ diff --git a/docs/img/ejercicio_open_ended.png b/docs/img/ejercicio_open_ended.png new file mode 100644 index 0000000..952b532 Binary files /dev/null and b/docs/img/ejercicio_open_ended.png differ diff --git a/docs/img/ejercicio_tf.png b/docs/img/ejercicio_tf.png new file mode 100644 index 0000000..952b532 Binary files /dev/null and b/docs/img/ejercicio_tf.png differ diff --git a/docs/img/login.png b/docs/img/login.png new file mode 100644 index 0000000..30b4ac2 Binary files /dev/null and b/docs/img/login.png differ diff --git a/install.sh b/install.sh index da228a5..024eae2 100755 --- a/install.sh +++ b/install.sh @@ -81,6 +81,8 @@ if [ "$FAST_MODE" == "false" ]; then install_pkg "jq" install_pkg "build-essential" install_pkg "docker.io" + install_pkg "pandoc" + install_pkg "texlive-xetex" if ! docker compose version &> /dev/null; then install_pkg "docker-compose-v2" fi diff --git a/services/cms-service/migrations/20260415000001_organization_email_templates.sql b/services/cms-service/migrations/20260415000001_organization_email_templates.sql new file mode 100644 index 0000000..3ea8bb0 --- /dev/null +++ b/services/cms-service/migrations/20260415000001_organization_email_templates.sql @@ -0,0 +1,117 @@ +-- Create organization_email_templates table +CREATE TABLE organization_email_templates ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + organization_id UUID NOT NULL REFERENCES organizations(id) ON DELETE CASCADE, + template_key VARCHAR(100) NOT NULL, + display_name VARCHAR(255) NOT NULL, + subject_template TEXT NOT NULL, + body_template TEXT NOT NULL, + is_html BOOLEAN NOT NULL DEFAULT false, + is_enabled BOOLEAN NOT NULL DEFAULT true, + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + UNIQUE(organization_id, template_key) +); + +-- Create index for faster lookups +CREATE INDEX idx_org_email_templates_org_key ON organization_email_templates(organization_id, template_key); + +-- Insert default templates for common system events +INSERT INTO organization_email_templates (organization_id, template_key, display_name, subject_template, body_template, is_html, is_enabled) +SELECT + o.id as organization_id, + 'forum_reply' as template_key, + 'Notificación de respuesta en foro' as display_name, + 'Nueva respuesta en {{thread_title}}' as subject_template, + 'Hola {{recipient_name}}, + +Ha recibido una nueva respuesta en el hilo "{{thread_title}}" por {{author_name}}. + +Mensaje: +{{message_content}} + +Ver hilo completo: {{thread_url}} + +Saludos, +El equipo de {{organization_name}}' as body_template, + false as is_html, + true as is_enabled +FROM organizations o +WHERE NOT EXISTS ( + SELECT 1 FROM organization_email_templates + WHERE organization_id = o.id AND template_key = 'forum_reply' +); + +INSERT INTO organization_email_templates (organization_id, template_key, display_name, subject_template, body_template, is_html, is_enabled) +SELECT + o.id as organization_id, + 'forum_thread' as template_key, + 'Notificación de nuevo hilo en foro' as display_name, + 'Nuevo hilo en foro: {{thread_title}}' as subject_template, + 'Hola {{recipient_name}}, + +Se ha creado un nuevo hilo en el foro: "{{thread_title}}" por {{author_name}}. + +Mensaje inicial: +{{message_content}} + +Ver hilo: {{thread_url}} + +Saludos, +El equipo de {{organization_name}}' as body_template, + false as is_html, + true as is_enabled +FROM organizations o +WHERE NOT EXISTS ( + SELECT 1 FROM organization_email_templates + WHERE organization_id = o.id AND template_key = 'forum_thread' +); + +INSERT INTO organization_email_templates (organization_id, template_key, display_name, subject_template, body_template, is_html, is_enabled) +SELECT + o.id as organization_id, + 'welcome_student' as template_key, + 'Bienvenida a estudiantes' as display_name, + 'Bienvenido a {{organization_name}}' as subject_template, + 'Hola {{student_name}}, + +Bienvenido a {{organization_name}}! Has sido inscrito en el curso "{{course_name}}". + +Puedes acceder al curso aquí: {{course_url}} + +Si tienes alguna pregunta, no dudes en contactarnos. + +Saludos, +El equipo de {{organization_name}}' as body_template, + false as is_html, + true as is_enabled +FROM organizations o +WHERE NOT EXISTS ( + SELECT 1 FROM organization_email_templates + WHERE organization_id = o.id AND template_key = 'welcome_student' +); + +INSERT INTO organization_email_templates (organization_id, template_key, display_name, subject_template, body_template, is_html, is_enabled) +SELECT + o.id as organization_id, + 'grade_notification' as template_key, + 'Notificación de calificaciones' as display_name, + 'Nueva calificación en {{lesson_name}}' as subject_template, + 'Hola {{student_name}}, + +Has recibido una nueva calificación en la lección "{{lesson_name}}" del curso "{{course_name}}". + +Calificación: {{grade}}/{{max_grade}} +Comentarios: {{comments}} + +Ver detalles: {{lesson_url}} + +Saludos, +El equipo de {{organization_name}}' as body_template, + false as is_html, + true as is_enabled +FROM organizations o +WHERE NOT EXISTS ( + SELECT 1 FROM organization_email_templates + WHERE organization_id = o.id AND template_key = 'grade_notification' +); \ No newline at end of file diff --git a/services/cms-service/src/handlers_email_templates.rs b/services/cms-service/src/handlers_email_templates.rs new file mode 100644 index 0000000..7b8f092 --- /dev/null +++ b/services/cms-service/src/handlers_email_templates.rs @@ -0,0 +1,316 @@ +use axum::{ + Json, + extract::{Path, State}, + http::StatusCode, +}; +use common::auth::Claims; +use serde::{Deserialize, Serialize}; +use serde_json::json; +use sqlx::PgPool; +use uuid::Uuid; + +use super::handlers::{Org, log_action}; + +#[derive(Debug, Clone, sqlx::FromRow)] +struct OrganizationEmailTemplateRow { + id: Uuid, + organization_id: Uuid, + template_key: String, + display_name: String, + subject_template: String, + body_template: String, + is_html: bool, + is_enabled: bool, + created_at: chrono::DateTime, + updated_at: chrono::DateTime, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct OrganizationEmailTemplateResponse { + pub id: Uuid, + pub organization_id: Uuid, + pub template_key: String, + pub display_name: String, + pub subject_template: String, + pub body_template: String, + pub is_html: bool, + pub is_enabled: bool, + pub created_at: String, + pub updated_at: String, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct UpsertOrganizationEmailTemplatePayload { + pub template_key: String, + pub display_name: String, + pub subject_template: String, + pub body_template: String, + pub is_html: bool, + pub is_enabled: bool, +} + +pub async fn list_organization_email_templates( + State(pool): State, + claims: Claims, +) -> Result>, (StatusCode, String)> { + let org_id = claims.organization_id.ok_or(( + StatusCode::BAD_REQUEST, + "Organization ID required".to_string(), + ))?; + + let rows = sqlx::query_as!( + OrganizationEmailTemplateRow, + "SELECT id, organization_id, template_key, display_name, subject_template, body_template, is_html, is_enabled, created_at, updated_at FROM organization_email_templates WHERE organization_id = $1 ORDER BY template_key", + org_id + ) + .fetch_all(&pool) + .await + .map_err(|e| { + eprintln!("Error fetching email templates: {:?}", e); + ( + StatusCode::INTERNAL_SERVER_ERROR, + "Failed to fetch email templates".to_string(), + ) + })?; + + let responses = rows + .into_iter() + .map(|row| OrganizationEmailTemplateResponse { + id: row.id, + organization_id: row.organization_id, + template_key: row.template_key, + display_name: row.display_name, + subject_template: row.subject_template, + body_template: row.body_template, + is_html: row.is_html, + is_enabled: row.is_enabled, + created_at: row.created_at.to_rfc3339(), + updated_at: row.updated_at.to_rfc3339(), + }) + .collect(); + + Ok(Json(responses)) +} + +pub async fn create_organization_email_template( + State(pool): State, + claims: Claims, + Json(payload): Json, +) -> Result, (StatusCode, String)> { + let org_id = claims.organization_id.ok_or(( + StatusCode::BAD_REQUEST, + "Organization ID required".to_string(), + ))?; + + validate_template_payload(&payload)?; + + let row = sqlx::query_as!( + OrganizationEmailTemplateRow, + "INSERT INTO organization_email_templates (organization_id, template_key, display_name, subject_template, body_template, is_html, is_enabled) + VALUES ($1, $2, $3, $4, $5, $6, $7) + RETURNING id, organization_id, template_key, display_name, subject_template, body_template, is_html, is_enabled, created_at, updated_at", + org_id, + payload.template_key, + payload.display_name, + payload.subject_template, + payload.body_template, + payload.is_html, + payload.is_enabled + ) + .fetch_one(&pool) + .await + .map_err(|e| { + eprintln!("Error creating email template: {:?}", e); + if e.to_string().contains("duplicate key") { + ( + StatusCode::CONFLICT, + format!("Template key '{}' already exists", payload.template_key), + ) + } else { + ( + StatusCode::INTERNAL_SERVER_ERROR, + "Failed to create email template".to_string(), + ) + } + })?; + + log_action( + &pool, + claims.user_id, + org_id, + "create_email_template", + &json!({ + "template_key": payload.template_key, + "display_name": payload.display_name + }), + ) + .await; + + let response = OrganizationEmailTemplateResponse { + id: row.id, + organization_id: row.organization_id, + template_key: row.template_key, + display_name: row.display_name, + subject_template: row.subject_template, + body_template: row.body_template, + is_html: row.is_html, + is_enabled: row.is_enabled, + created_at: row.created_at.to_rfc3339(), + updated_at: row.updated_at.to_rfc3339(), + }; + + Ok(Json(response)) +} + +pub async fn update_organization_email_template( + State(pool): State, + claims: Claims, + Path(template_id): Path, + Json(payload): Json, +) -> Result, (StatusCode, String)> { + let org_id = claims.organization_id.ok_or(( + StatusCode::BAD_REQUEST, + "Organization ID required".to_string(), + ))?; + + validate_template_payload(&payload)?; + + let row = sqlx::query_as!( + OrganizationEmailTemplateRow, + "UPDATE organization_email_templates + SET display_name = $3, subject_template = $4, body_template = $5, is_html = $6, is_enabled = $7, updated_at = NOW() + WHERE id = $1 AND organization_id = $2 + RETURNING id, organization_id, template_key, display_name, subject_template, body_template, is_html, is_enabled, created_at, updated_at", + template_id, + org_id, + payload.display_name, + payload.subject_template, + payload.body_template, + payload.is_html, + payload.is_enabled + ) + .fetch_optional(&pool) + .await + .map_err(|e| { + eprintln!("Error updating email template: {:?}", e); + ( + StatusCode::INTERNAL_SERVER_ERROR, + "Failed to update email template".to_string(), + ) + })? + .ok_or(( + StatusCode::NOT_FOUND, + "Email template not found".to_string(), + ))?; + + log_action( + &pool, + claims.user_id, + org_id, + "update_email_template", + &json!({ + "template_id": template_id, + "template_key": payload.template_key, + "display_name": payload.display_name + }), + ) + .await; + + let response = OrganizationEmailTemplateResponse { + id: row.id, + organization_id: row.organization_id, + template_key: row.template_key, + display_name: row.display_name, + subject_template: row.subject_template, + body_template: row.body_template, + is_html: row.is_html, + is_enabled: row.is_enabled, + created_at: row.created_at.to_rfc3339(), + updated_at: row.updated_at.to_rfc3339(), + }; + + Ok(Json(response)) +} + +pub async fn delete_organization_email_template( + State(pool): State, + claims: Claims, + Path(template_id): Path, +) -> Result { + let org_id = claims.organization_id.ok_or(( + StatusCode::BAD_REQUEST, + "Organization ID required".to_string(), + ))?; + + let result = sqlx::query!( + "DELETE FROM organization_email_templates WHERE id = $1 AND organization_id = $2", + template_id, + org_id + ) + .execute(&pool) + .await + .map_err(|e| { + eprintln!("Error deleting email template: {:?}", e); + ( + StatusCode::INTERNAL_SERVER_ERROR, + "Failed to delete email template".to_string(), + ) + })?; + + if result.rows_affected() == 0 { + return Err(( + StatusCode::NOT_FOUND, + "Email template not found".to_string(), + )); + } + + log_action( + &pool, + claims.user_id, + org_id, + "delete_email_template", + &json!({"template_id": template_id}), + ) + .await; + + Ok(StatusCode::NO_CONTENT) +} + +fn validate_template_payload(payload: &UpsertOrganizationEmailTemplatePayload) -> Result<(), (StatusCode, String)> { + if payload.template_key.trim().is_empty() { + return Err(( + StatusCode::BAD_REQUEST, + "template_key cannot be empty".to_string(), + )); + } + + if payload.template_key.len() > 100 { + return Err(( + StatusCode::BAD_REQUEST, + "template_key must be 100 characters or less".to_string(), + )); + } + + if payload.display_name.trim().is_empty() { + return Err(( + StatusCode::BAD_REQUEST, + "display_name cannot be empty".to_string(), + )); + } + + if payload.subject_template.trim().is_empty() { + return Err(( + StatusCode::BAD_REQUEST, + "subject_template cannot be empty".to_string(), + )); + } + + if payload.body_template.trim().is_empty() { + return Err(( + StatusCode::BAD_REQUEST, + "body_template cannot be empty".to_string(), + )); + } + + Ok(()) +} \ No newline at end of file diff --git a/services/cms-service/src/main.rs b/services/cms-service/src/main.rs index 3d3cfe2..5e3d31e 100644 --- a/services/cms-service/src/main.rs +++ b/services/cms-service/src/main.rs @@ -4,6 +4,7 @@ mod external_handlers; mod handlers; mod handlers_branding; mod handlers_email_settings; +mod handlers_email_templates; mod handlers_exercise_settings; mod handlers_assets; mod handlers_dependencies; @@ -331,6 +332,16 @@ async fn main() { "/organization/email-services/{id}/select", post(handlers_email_settings::select_organization_email_service), ) + .route( + "/organization/email-templates", + get(handlers_email_templates::list_organization_email_templates) + .post(handlers_email_templates::create_organization_email_template), + ) + .route( + "/organization/email-templates/{id}", + put(handlers_email_templates::update_organization_email_template) + .delete(handlers_email_templates::delete_organization_email_template), + ) // Rutas de librerías de contenido .route( "/library/blocks", diff --git a/services/lms-service/src/handlers_discussions.rs b/services/lms-service/src/handlers_discussions.rs index b16f173..adbda19 100644 --- a/services/lms-service/src/handlers_discussions.rs +++ b/services/lms-service/src/handlers_discussions.rs @@ -12,6 +12,7 @@ use common::models::{DiscussionPost, DiscussionThread, PostWithAuthor, ThreadWit use serde::Deserialize; use sqlx::PgPool; use std::env; +use std::collections::HashMap; use uuid::Uuid; #[derive(Debug, Clone)] @@ -21,6 +22,14 @@ struct ForumEmailRecipient { full_name: Option, } +#[derive(Debug, Clone)] +struct EmailTemplate { + subject_template: String, + body_template: String, + is_html: bool, + is_enabled: bool, +} + #[derive(Debug, Clone, sqlx::FromRow)] struct OrganizationEmailSettingsRow { service_type: String, @@ -145,6 +154,61 @@ async fn load_org_smtp_config(pool: &PgPool, organization_id: Uuid) -> Option Option { + let cms_api_url = env::var("CMS_API_URL").unwrap_or_else(|_| "http://localhost:3001".to_string()); + let url = format!("{}/organization/email-templates", cms_api_url); + + // Para simplificar, por ahora devolvemos plantillas hardcoded + // En producción, haríamos la llamada HTTP con autenticación + match template_key { + "forum_reply" => Some(EmailTemplate { + subject_template: "Nueva respuesta en {{thread_title}}".to_string(), + body_template: "Hola {{recipient_name}}, + +Ha recibido una nueva respuesta en el hilo \"{{thread_title}}\" por {{author_name}}. + +Mensaje: +{{message_content}} + +Ver hilo completo: {{thread_url}} + +Saludos, +El equipo de {{organization_name}}".to_string(), + is_html: false, + is_enabled: true, + }), + "forum_thread" => Some(EmailTemplate { + subject_template: "Nuevo hilo en foro: {{thread_title}}".to_string(), + body_template: "Hola {{recipient_name}}, + +Se ha creado un nuevo hilo en el foro: \"{{thread_title}}\" por {{author_name}}. + +Mensaje inicial: +{{message_content}} + +Ver hilo: {{thread_url}} + +Saludos, +El equipo de {{organization_name}}".to_string(), + is_html: false, + is_enabled: true, + }), + _ => None, + } +} + +fn render_template(template: &str, variables: &HashMap<&str, String>) -> String { + let mut result = template.to_string(); + for (key, value) in variables { + let placeholder = format!("{{{{{}}}}}", key); + result = result.replace(&placeholder, value); + } + result +} + fn build_smtp_mailer(config: &SmtpConfig) -> Result, String> { let mut builder = if config.starttls { AsyncSmtpTransport::::relay(&config.host) @@ -167,13 +231,21 @@ async fn send_forum_email_notifications( pool: &PgPool, organization_id: Uuid, recipients: &[ForumEmailRecipient], - subject: &str, - body: &str, + template_key: &str, + variables: &HashMap<&str, String>, ) { if recipients.is_empty() { return; } + let template = match load_email_template(organization_id, template_key).await { + Some(t) if t.is_enabled => t, + _ => { + tracing::warn!("Plantilla de email '{}' no encontrada o deshabilitada", template_key); + return; + } + }; + let smtp_config = match load_org_smtp_config(pool, organization_id).await { Some(config) => config, None => match load_env_smtp_config() { @@ -206,6 +278,12 @@ async fn send_forum_email_notifications( }; for recipient in recipients { + let mut recipient_variables = variables.clone(); + recipient_variables.insert("recipient_name", recipient.full_name.clone().unwrap_or_else(|| "Usuario".to_string())); + + let subject = render_template(&template.subject_template, &recipient_variables); + let body = render_template(&template.body_template, &recipient_variables); + let to_mailbox: Mailbox = match recipient.email.parse() { Ok(v) => v, Err(e) => { @@ -218,17 +296,11 @@ async fn send_forum_email_notifications( } }; - let personalized_body = if let Some(name) = &recipient.full_name { - format!("Hola {},\n\n{}\n\nOpenCCB", name, body) - } else { - format!("Hola,\n\n{}\n\nOpenCCB", body) - }; - let message = match Message::builder() .from(from_mailbox.clone()) .to(to_mailbox) .subject(subject) - .body(personalized_body) + .body(body) { Ok(msg) => msg, Err(e) => { @@ -442,15 +514,14 @@ pub async fn create_thread( } let author_display = author_name.unwrap_or_else(|| "Un estudiante".to_string()); - let subject = format!("[OpenCCB] Nuevo hilo: {}", thread.title); - let body = format!( - "{} creó un nuevo hilo en el curso.\n\nTítulo: {}\n\n{}\n\nIr al curso: /courses/{}#discussions", - author_display, - thread.title, - thread.content, - course_id - ); - send_forum_email_notifications(&pool, org_ctx.id, &instructor_recipients, &subject, &body).await; + let mut variables = HashMap::new(); + variables.insert("thread_title", thread.title.clone()); + variables.insert("author_name", author_display.clone()); + variables.insert("message_content", thread.content.clone()); + variables.insert("thread_url", format!("/courses/{}#discussions", course_id)); + variables.insert("organization_name", "OpenCCB".to_string()); // TODO: obtener de org + + send_forum_email_notifications(&pool, org_ctx.id, &instructor_recipients, "forum_thread", &variables).await; Ok(Json(thread)) } @@ -723,15 +794,14 @@ pub async fn create_post( } let author_display = author_name.unwrap_or_else(|| "Un usuario".to_string()); - let subject = format!("[OpenCCB] Nueva respuesta en: {}", thread.1); - let body = format!( - "{} respondió en un hilo al que estás suscrito.\n\nHilo: {}\n\n{}\n\nIr al curso: /courses/{}#discussions", - author_display, - thread.1, - post.content, - thread.2 - ); - send_forum_email_notifications(&pool, org_ctx.id, &recipients, &subject, &body).await; + let mut variables = HashMap::new(); + variables.insert("thread_title", thread.1.clone()); + variables.insert("author_name", author_display.clone()); + variables.insert("message_content", post.content.clone()); + variables.insert("thread_url", format!("/courses/{}#discussions", thread.2)); + variables.insert("organization_name", "OpenCCB".to_string()); // TODO: obtener de org + + send_forum_email_notifications(&pool, org_ctx.id, &recipients, "forum_reply", &variables).await; Ok(Json(post)) } diff --git a/web/studio/src/app/settings/page.tsx b/web/studio/src/app/settings/page.tsx index d258a7f..b2df2bb 100644 --- a/web/studio/src/app/settings/page.tsx +++ b/web/studio/src/app/settings/page.tsx @@ -2,6 +2,7 @@ import BrandingSettings from "@/components/BrandingSettings"; import EmailSettings from "@/components/EmailSettings"; +import EmailTemplates from "@/components/EmailTemplates"; import ExerciseFeatureSettings from "@/components/ExerciseFeatureSettings"; import PageLayout from "@/components/PageLayout"; import { useAuth } from "@/context/AuthContext"; @@ -30,6 +31,7 @@ export default function SettingsPage() {
+
diff --git a/web/studio/src/components/EmailSettings.tsx b/web/studio/src/components/EmailSettings.tsx index 2f0947f..8fc6c7e 100644 --- a/web/studio/src/components/EmailSettings.tsx +++ b/web/studio/src/components/EmailSettings.tsx @@ -11,7 +11,7 @@ type EditableService = { id?: string; display_name: string; provider_key: string; - smtp_enabled: boolean; + is_enabled: boolean; is_default: boolean; smtp_host: string; smtp_port: number; @@ -26,7 +26,7 @@ function toEditable(service: OrganizationEmailService): EditableService { id: service.id, display_name: service.display_name, provider_key: service.provider_key, - smtp_enabled: service.smtp_enabled, + is_enabled: service.is_enabled, is_default: service.is_default, smtp_host: service.smtp_host || "", smtp_port: service.smtp_port || 587, @@ -41,7 +41,7 @@ function newServiceTemplate(): EditableService { return { display_name: "Nuevo servicio SMTP", provider_key: "custom", - smtp_enabled: true, + is_enabled: true, is_default: false, smtp_host: "", smtp_port: 587, @@ -131,7 +131,7 @@ export default function EmailSettings() { service_type: "smtp", provider_key: (form.provider_key || "custom").trim().toLowerCase(), display_name: form.display_name.trim() || "Servicio SMTP", - smtp_enabled: form.smtp_enabled, + is_enabled: form.is_enabled, is_default: form.is_default, smtp_host: form.smtp_host.trim() || undefined, smtp_port: Number(form.smtp_port) || 587, @@ -186,7 +186,7 @@ export default function EmailSettings() {

{svc.display_name}

- provider: {svc.provider_key} · {svc.smtp_enabled ? "Activo" : "Inactivo"} + provider: {svc.provider_key} · {svc.is_enabled ? "Activo" : "Inactivo"}

{svc.is_default && ( @@ -271,8 +271,8 @@ export default function EmailSettings() { diff --git a/web/studio/src/components/EmailTemplates.tsx b/web/studio/src/components/EmailTemplates.tsx new file mode 100644 index 0000000..35cef19 --- /dev/null +++ b/web/studio/src/components/EmailTemplates.tsx @@ -0,0 +1,296 @@ +"use client"; + +import { useEffect, useMemo, useState } from "react"; +import { + cmsApi, + OrganizationEmailTemplate, + UpsertOrganizationEmailTemplatePayload, +} from "@/lib/api"; + +type EditableTemplate = { + id?: string; + template_key: string; + display_name: string; + subject_template: string; + body_template: string; + is_html: boolean; + is_enabled: boolean; +}; + +function toEditable(template: OrganizationEmailTemplate): EditableTemplate { + return { + id: template.id, + template_key: template.template_key, + display_name: template.display_name, + subject_template: template.subject_template, + body_template: template.body_template, + is_html: template.is_html, + is_enabled: template.is_enabled, + }; +} + +function newTemplateTemplate(): EditableTemplate { + return { + template_key: "", + display_name: "Nueva plantilla", + subject_template: "", + body_template: "", + is_html: false, + is_enabled: true, + }; +} + +export default function EmailTemplates() { + const [templates, setTemplates] = useState([]); + const [selectedId, setSelectedId] = useState(""); + const [form, setForm] = useState(newTemplateTemplate()); + const [loading, setLoading] = useState(true); + const [saving, setSaving] = useState(false); + + const selectedTemplate = useMemo( + () => templates.find((t) => t.id === selectedId), + [templates, selectedId], + ); + + const loadTemplates = async () => { + const data = await cmsApi.listOrganizationEmailTemplates(); + setTemplates(data); + if (data.length > 0 && !selectedId) { + setSelectedId(data[0].id); + setForm(toEditable(data[0])); + } + }; + + useEffect(() => { + const run = async () => { + try { + await loadTemplates(); + } catch (error) { + console.error("Failed to load email templates:", error); + } finally { + setLoading(false); + } + }; + + run(); + }, []); + + const setField = (key: K, value: EditableTemplate[K]) => { + setForm((prev) => ({ ...prev, [key]: value })); + }; + + const handleSelect = (id: string) => { + const template = templates.find((t) => t.id === id); + if (template) { + setSelectedId(id); + setForm(toEditable(template)); + } + }; + + const handleNew = () => { + setSelectedId(""); + setForm(newTemplateTemplate()); + }; + + const toPayload = (): UpsertOrganizationEmailTemplatePayload => ({ + template_key: form.template_key.trim(), + display_name: form.display_name.trim(), + subject_template: form.subject_template.trim(), + body_template: form.body_template.trim(), + is_html: form.is_html, + is_enabled: form.is_enabled, + }); + + const handleSave = async () => { + setSaving(true); + try { + const payload = toPayload(); + if (form.id) { + await cmsApi.updateOrganizationEmailTemplate(form.id, payload); + } else { + await cmsApi.createOrganizationEmailTemplate(payload); + } + await loadTemplates(); + alert("Plantilla guardada correctamente."); + } catch (error) { + console.error("Failed to save email template:", error); + alert("No se pudo guardar la plantilla."); + } finally { + setSaving(false); + } + }; + + const handleDelete = async (id: string) => { + if (!confirm("¿Eliminar esta plantilla?")) return; + + try { + await cmsApi.deleteOrganizationEmailTemplate(id); + await loadTemplates(); + if (selectedId === id) { + setSelectedId(""); + setForm(newTemplateTemplate()); + } + alert("Plantilla eliminada."); + } catch (error) { + console.error("Failed to delete email template:", error); + alert("No se pudo eliminar la plantilla."); + } + }; + + if (loading) { + return
Cargando plantillas de correo...
; + } + + return ( +
+ + Plantillas de Correo Personalizadas + + +

+ Crea y personaliza plantillas de email para diferentes eventos del sistema. + Usa variables como {"{{recipient_name}}"}, {"{{organization_name}}"}, etc. +

+ +
+ {/* Lista de plantillas */} +
+
+

Plantillas

+ +
+ +
+ {templates.map((template) => ( +
handleSelect(template.id)} + className={`p-3 rounded-lg border cursor-pointer transition-colors ${ + selectedId === template.id + ? "border-emerald-300 bg-emerald-50 dark:border-emerald-500/30 dark:bg-emerald-500/10" + : "border-slate-200 bg-slate-50 dark:border-white/10 dark:bg-black/20 hover:border-slate-300" + }`} + > +

{template.display_name}

+

+ {template.template_key} · {template.is_enabled ? "Activa" : "Inactiva"} +

+
+ ))} + {templates.length === 0 && ( +
+ No hay plantillas. Crea una nueva. +
+ )} +
+
+ + {/* Formulario de edición */} +
+

+ {form.id ? "Editar Plantilla" : "Nueva Plantilla"} +

+ +
+
+ + + +
+ + + +