feat: add organization email templates management

- Created a new SQL migration to establish the organization_email_templates table with necessary fields and default templates for common events.
- Implemented CRUD operations for email templates in Rust, including listing, creating, updating, and deleting templates.
- Developed a React component for managing email templates, allowing users to create, edit, and delete templates with a user-friendly interface.
This commit is contained in:
2026-04-15 10:26:44 -04:00
parent e1d5975e57
commit 6f8b723d64
18 changed files with 997 additions and 36 deletions
Binary file not shown.
+118
View File
@@ -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`)*
Binary file not shown.

After

Width:  |  Height:  |  Size: 496 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 496 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 496 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 496 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 496 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 496 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 443 KiB

+2
View File
@@ -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
@@ -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'
);
@@ -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<chrono::Utc>,
updated_at: chrono::DateTime<chrono::Utc>,
}
#[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<PgPool>,
claims: Claims,
) -> Result<Json<Vec<OrganizationEmailTemplateResponse>>, (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<PgPool>,
claims: Claims,
Json(payload): Json<UpsertOrganizationEmailTemplatePayload>,
) -> Result<Json<OrganizationEmailTemplateResponse>, (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<PgPool>,
claims: Claims,
Path(template_id): Path<Uuid>,
Json(payload): Json<UpsertOrganizationEmailTemplatePayload>,
) -> Result<Json<OrganizationEmailTemplateResponse>, (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<PgPool>,
claims: Claims,
Path(template_id): Path<Uuid>,
) -> Result<StatusCode, (StatusCode, String)> {
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(())
}
+11
View File
@@ -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",
@@ -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<String>,
}
#[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<Sm
})
}
async fn load_email_template(
organization_id: Uuid,
template_key: &str,
) -> Option<EmailTemplate> {
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<AsyncSmtpTransport<Tokio1Executor>, String> {
let mut builder = if config.starttls {
AsyncSmtpTransport::<Tokio1Executor>::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))
}
+2
View File
@@ -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() {
<div className="space-y-8">
<BrandingSettings />
<EmailSettings />
<EmailTemplates />
<ExerciseFeatureSettings />
</div>
</PageLayout>
+7 -7
View File
@@ -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() {
<div>
<h3 className="text-sm font-bold text-slate-900 dark:text-white">{svc.display_name}</h3>
<p className="text-xs text-slate-500 dark:text-gray-400 mt-1">
provider: {svc.provider_key} · {svc.smtp_enabled ? "Activo" : "Inactivo"}
provider: {svc.provider_key} · {svc.is_enabled ? "Activo" : "Inactivo"}
</p>
</div>
{svc.is_default && (
@@ -271,8 +271,8 @@ export default function EmailSettings() {
<label className="flex items-center gap-3 mt-6">
<input
type="checkbox"
checked={form.smtp_enabled}
onChange={(e) => setField("smtp_enabled", e.target.checked)}
checked={form.is_enabled}
onChange={(e) => setField("is_enabled", e.target.checked)}
/>
<span className="text-sm text-slate-800 dark:text-gray-200">Habilitado</span>
</label>
@@ -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<OrganizationEmailTemplate[]>([]);
const [selectedId, setSelectedId] = useState<string>("");
const [form, setForm] = useState<EditableTemplate>(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 = <K extends keyof EditableTemplate>(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 <div className="p-8 text-center text-gray-400 animate-pulse">Cargando plantillas de correo...</div>;
}
return (
<fieldset className="border border-slate-200 dark:border-white/10 rounded-2xl p-6 bg-white dark:bg-white/5 backdrop-blur-sm shadow-sm">
<legend className="px-2 text-xl font-bold flex items-center gap-2 text-slate-900 dark:text-white">
<span aria-hidden="true">📧</span> Plantillas de Correo Personalizadas
</legend>
<p className="text-sm text-slate-600 dark:text-gray-400 mt-4">
Crea y personaliza plantillas de email para diferentes eventos del sistema.
Usa variables como {"{{recipient_name}}"}, {"{{organization_name}}"}, etc.
</p>
<div className="mt-6 grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* Lista de plantillas */}
<div className="lg:col-span-1">
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-semibold text-slate-900 dark:text-white">Plantillas</h3>
<button
onClick={handleNew}
className="px-3 py-1 text-sm bg-emerald-600 hover:bg-emerald-700 text-white rounded-lg"
>
Nueva
</button>
</div>
<div className="space-y-2 max-h-96 overflow-y-auto">
{templates.map((template) => (
<div
key={template.id}
onClick={() => 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"
}`}
>
<h4 className="font-medium text-slate-900 dark:text-white">{template.display_name}</h4>
<p className="text-xs text-slate-500 dark:text-gray-400 mt-1">
{template.template_key} · {template.is_enabled ? "Activa" : "Inactiva"}
</p>
</div>
))}
{templates.length === 0 && (
<div className="p-4 text-center text-slate-500 dark:text-gray-400">
No hay plantillas. Crea una nueva.
</div>
)}
</div>
</div>
{/* Formulario de edición */}
<div className="lg:col-span-2">
<h3 className="text-lg font-semibold text-slate-900 dark:text-white mb-4">
{form.id ? "Editar Plantilla" : "Nueva Plantilla"}
</h3>
<div className="space-y-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<label className="block">
<span className="block text-xs font-semibold uppercase tracking-wider text-slate-500 dark:text-gray-400 mb-1">
Clave de plantilla
</span>
<input
type="text"
value={form.template_key}
onChange={(e) => setField("template_key", e.target.value)}
placeholder="forum_reply, welcome_student, etc."
className="w-full rounded-lg border border-slate-300 dark:border-white/20 bg-white dark:bg-slate-900/40 px-3 py-2 text-sm"
/>
</label>
<label className="block">
<span className="block text-xs font-semibold uppercase tracking-wider text-slate-500 dark:text-gray-400 mb-1">
Nombre para mostrar
</span>
<input
type="text"
value={form.display_name}
onChange={(e) => setField("display_name", e.target.value)}
className="w-full rounded-lg border border-slate-300 dark:border-white/20 bg-white dark:bg-slate-900/40 px-3 py-2 text-sm"
/>
</label>
</div>
<label className="block">
<span className="block text-xs font-semibold uppercase tracking-wider text-slate-500 dark:text-gray-400 mb-1">
Asunto
</span>
<input
type="text"
value={form.subject_template}
onChange={(e) => setField("subject_template", e.target.value)}
placeholder="Ej: Nueva respuesta en {{thread_title}}"
className="w-full rounded-lg border border-slate-300 dark:border-white/20 bg-white dark:bg-slate-900/40 px-3 py-2 text-sm"
/>
</label>
<label className="block">
<span className="block text-xs font-semibold uppercase tracking-wider text-slate-500 dark:text-gray-400 mb-1">
Cuerpo del mensaje
</span>
<textarea
value={form.body_template}
onChange={(e) => setField("body_template", e.target.value)}
rows={12}
placeholder="Usa variables como {{recipient_name}}, {{organization_name}}, etc."
className="w-full rounded-lg border border-slate-300 dark:border-white/20 bg-white dark:bg-slate-900/40 px-3 py-2 text-sm font-mono"
/>
</label>
<div className="flex items-center gap-6">
<label className="flex items-center gap-3">
<input
type="checkbox"
checked={form.is_enabled}
onChange={(e) => setField("is_enabled", e.target.checked)}
/>
<span className="text-sm text-slate-800 dark:text-gray-200">Habilitada</span>
</label>
<label className="flex items-center gap-3">
<input
type="checkbox"
checked={form.is_html}
onChange={(e) => setField("is_html", e.target.checked)}
/>
<span className="text-sm text-slate-800 dark:text-gray-200">HTML</span>
</label>
</div>
<div className="flex gap-3 pt-4">
<button
onClick={handleSave}
disabled={saving}
className="px-4 py-2 bg-emerald-600 hover:bg-emerald-700 disabled:bg-emerald-400 text-white rounded-lg text-sm font-medium"
>
{saving ? "Guardando..." : "Guardar"}
</button>
{form.id && (
<button
onClick={() => handleDelete(form.id!)}
className="px-4 py-2 bg-red-600 hover:bg-red-700 text-white rounded-lg text-sm font-medium"
>
Eliminar
</button>
)}
</div>
</div>
</div>
</div>
</fieldset>
);
}
+31 -2
View File
@@ -308,7 +308,7 @@ export interface OrganizationEmailService {
service_type: string;
provider_key: string;
display_name: string;
smtp_enabled: boolean;
is_enabled: boolean;
is_default: boolean;
smtp_host?: string;
smtp_port: number;
@@ -322,7 +322,7 @@ export interface UpsertOrganizationEmailServicePayload {
service_type: string;
provider_key: string;
display_name: string;
smtp_enabled: boolean;
is_enabled: boolean;
is_default: boolean;
smtp_host?: string;
smtp_port: number;
@@ -332,6 +332,28 @@ export interface UpsertOrganizationEmailServicePayload {
smtp_starttls: boolean;
}
export interface OrganizationEmailTemplate {
id: string;
organization_id: string;
template_key: string;
display_name: string;
subject_template: string;
body_template: string;
is_html: bool;
is_enabled: bool;
created_at: string;
updated_at: string;
}
export interface UpsertOrganizationEmailTemplatePayload {
template_key: string;
display_name: string;
subject_template: string;
body_template: string;
is_html: bool;
is_enabled: bool;
}
export interface ProvisionPayload {
org_name: string;
org_domain?: string;
@@ -923,6 +945,13 @@ export const cmsApi = {
apiFetch(`/organization/email-services/${id}`, { method: 'DELETE' }),
selectOrganizationEmailService: (id: string): Promise<void> =>
apiFetch(`/organization/email-services/${id}/select`, { method: 'POST' }),
listOrganizationEmailTemplates: (): Promise<OrganizationEmailTemplate[]> => apiFetch('/organization/email-templates'),
createOrganizationEmailTemplate: (payload: UpsertOrganizationEmailTemplatePayload): Promise<OrganizationEmailTemplate> =>
apiFetch('/organization/email-templates', { method: 'POST', body: JSON.stringify(payload) }),
updateOrganizationEmailTemplate: (id: string, payload: UpsertOrganizationEmailTemplatePayload): Promise<OrganizationEmailTemplate> =>
apiFetch(`/organization/email-templates/${id}`, { method: 'PUT', body: JSON.stringify(payload) }),
deleteOrganizationEmailTemplate: (id: string): Promise<void> =>
apiFetch(`/organization/email-templates/${id}`, { method: 'DELETE' }),
getOrganizationExerciseSettings: (): Promise<OrganizationExerciseSettings> => apiFetch('/organization/exercise-settings'),
updateOrganizationExerciseSettings: (payload: Omit<OrganizationExerciseSettings, 'organization_id'>): Promise<OrganizationExerciseSettings> =>
apiFetch('/organization/exercise-settings', { method: 'PUT', body: JSON.stringify(payload) }),