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.
@@ -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`*
|
||||||
|
> 
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 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`*
|
||||||
|
> 
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 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"]*
|
||||||
|
> 
|
||||||
|
|
||||||
|
### 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"]*
|
||||||
|
> 
|
||||||
|
|
||||||
|
### 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]*
|
||||||
|
> 
|
||||||
|
|
||||||
|
### 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]*
|
||||||
|
> 
|
||||||
|
|
||||||
|
### 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]*
|
||||||
|
> 
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 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`)*
|
||||||
|
After Width: | Height: | Size: 496 KiB |
|
After Width: | Height: | Size: 496 KiB |
|
After Width: | Height: | Size: 496 KiB |
|
After Width: | Height: | Size: 496 KiB |
|
After Width: | Height: | Size: 496 KiB |
|
After Width: | Height: | Size: 496 KiB |
|
After Width: | Height: | Size: 443 KiB |
@@ -81,6 +81,8 @@ if [ "$FAST_MODE" == "false" ]; then
|
|||||||
install_pkg "jq"
|
install_pkg "jq"
|
||||||
install_pkg "build-essential"
|
install_pkg "build-essential"
|
||||||
install_pkg "docker.io"
|
install_pkg "docker.io"
|
||||||
|
install_pkg "pandoc"
|
||||||
|
install_pkg "texlive-xetex"
|
||||||
if ! docker compose version &> /dev/null; then
|
if ! docker compose version &> /dev/null; then
|
||||||
install_pkg "docker-compose-v2"
|
install_pkg "docker-compose-v2"
|
||||||
fi
|
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(())
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ mod external_handlers;
|
|||||||
mod handlers;
|
mod handlers;
|
||||||
mod handlers_branding;
|
mod handlers_branding;
|
||||||
mod handlers_email_settings;
|
mod handlers_email_settings;
|
||||||
|
mod handlers_email_templates;
|
||||||
mod handlers_exercise_settings;
|
mod handlers_exercise_settings;
|
||||||
mod handlers_assets;
|
mod handlers_assets;
|
||||||
mod handlers_dependencies;
|
mod handlers_dependencies;
|
||||||
@@ -331,6 +332,16 @@ async fn main() {
|
|||||||
"/organization/email-services/{id}/select",
|
"/organization/email-services/{id}/select",
|
||||||
post(handlers_email_settings::select_organization_email_service),
|
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
|
// Rutas de librerías de contenido
|
||||||
.route(
|
.route(
|
||||||
"/library/blocks",
|
"/library/blocks",
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ use common::models::{DiscussionPost, DiscussionThread, PostWithAuthor, ThreadWit
|
|||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
use sqlx::PgPool;
|
use sqlx::PgPool;
|
||||||
use std::env;
|
use std::env;
|
||||||
|
use std::collections::HashMap;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
@@ -21,6 +22,14 @@ struct ForumEmailRecipient {
|
|||||||
full_name: Option<String>,
|
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)]
|
#[derive(Debug, Clone, sqlx::FromRow)]
|
||||||
struct OrganizationEmailSettingsRow {
|
struct OrganizationEmailSettingsRow {
|
||||||
service_type: String,
|
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> {
|
fn build_smtp_mailer(config: &SmtpConfig) -> Result<AsyncSmtpTransport<Tokio1Executor>, String> {
|
||||||
let mut builder = if config.starttls {
|
let mut builder = if config.starttls {
|
||||||
AsyncSmtpTransport::<Tokio1Executor>::relay(&config.host)
|
AsyncSmtpTransport::<Tokio1Executor>::relay(&config.host)
|
||||||
@@ -167,13 +231,21 @@ async fn send_forum_email_notifications(
|
|||||||
pool: &PgPool,
|
pool: &PgPool,
|
||||||
organization_id: Uuid,
|
organization_id: Uuid,
|
||||||
recipients: &[ForumEmailRecipient],
|
recipients: &[ForumEmailRecipient],
|
||||||
subject: &str,
|
template_key: &str,
|
||||||
body: &str,
|
variables: &HashMap<&str, String>,
|
||||||
) {
|
) {
|
||||||
if recipients.is_empty() {
|
if recipients.is_empty() {
|
||||||
return;
|
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 {
|
let smtp_config = match load_org_smtp_config(pool, organization_id).await {
|
||||||
Some(config) => config,
|
Some(config) => config,
|
||||||
None => match load_env_smtp_config() {
|
None => match load_env_smtp_config() {
|
||||||
@@ -206,6 +278,12 @@ async fn send_forum_email_notifications(
|
|||||||
};
|
};
|
||||||
|
|
||||||
for recipient in recipients {
|
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() {
|
let to_mailbox: Mailbox = match recipient.email.parse() {
|
||||||
Ok(v) => v,
|
Ok(v) => v,
|
||||||
Err(e) => {
|
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()
|
let message = match Message::builder()
|
||||||
.from(from_mailbox.clone())
|
.from(from_mailbox.clone())
|
||||||
.to(to_mailbox)
|
.to(to_mailbox)
|
||||||
.subject(subject)
|
.subject(subject)
|
||||||
.body(personalized_body)
|
.body(body)
|
||||||
{
|
{
|
||||||
Ok(msg) => msg,
|
Ok(msg) => msg,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
@@ -442,15 +514,14 @@ pub async fn create_thread(
|
|||||||
}
|
}
|
||||||
|
|
||||||
let author_display = author_name.unwrap_or_else(|| "Un estudiante".to_string());
|
let author_display = author_name.unwrap_or_else(|| "Un estudiante".to_string());
|
||||||
let subject = format!("[OpenCCB] Nuevo hilo: {}", thread.title);
|
let mut variables = HashMap::new();
|
||||||
let body = format!(
|
variables.insert("thread_title", thread.title.clone());
|
||||||
"{} creó un nuevo hilo en el curso.\n\nTítulo: {}\n\n{}\n\nIr al curso: /courses/{}#discussions",
|
variables.insert("author_name", author_display.clone());
|
||||||
author_display,
|
variables.insert("message_content", thread.content.clone());
|
||||||
thread.title,
|
variables.insert("thread_url", format!("/courses/{}#discussions", course_id));
|
||||||
thread.content,
|
variables.insert("organization_name", "OpenCCB".to_string()); // TODO: obtener de org
|
||||||
course_id
|
|
||||||
);
|
send_forum_email_notifications(&pool, org_ctx.id, &instructor_recipients, "forum_thread", &variables).await;
|
||||||
send_forum_email_notifications(&pool, org_ctx.id, &instructor_recipients, &subject, &body).await;
|
|
||||||
|
|
||||||
Ok(Json(thread))
|
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 author_display = author_name.unwrap_or_else(|| "Un usuario".to_string());
|
||||||
let subject = format!("[OpenCCB] Nueva respuesta en: {}", thread.1);
|
let mut variables = HashMap::new();
|
||||||
let body = format!(
|
variables.insert("thread_title", thread.1.clone());
|
||||||
"{} respondió en un hilo al que estás suscrito.\n\nHilo: {}\n\n{}\n\nIr al curso: /courses/{}#discussions",
|
variables.insert("author_name", author_display.clone());
|
||||||
author_display,
|
variables.insert("message_content", post.content.clone());
|
||||||
thread.1,
|
variables.insert("thread_url", format!("/courses/{}#discussions", thread.2));
|
||||||
post.content,
|
variables.insert("organization_name", "OpenCCB".to_string()); // TODO: obtener de org
|
||||||
thread.2
|
|
||||||
);
|
send_forum_email_notifications(&pool, org_ctx.id, &recipients, "forum_reply", &variables).await;
|
||||||
send_forum_email_notifications(&pool, org_ctx.id, &recipients, &subject, &body).await;
|
|
||||||
|
|
||||||
Ok(Json(post))
|
Ok(Json(post))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import BrandingSettings from "@/components/BrandingSettings";
|
import BrandingSettings from "@/components/BrandingSettings";
|
||||||
import EmailSettings from "@/components/EmailSettings";
|
import EmailSettings from "@/components/EmailSettings";
|
||||||
|
import EmailTemplates from "@/components/EmailTemplates";
|
||||||
import ExerciseFeatureSettings from "@/components/ExerciseFeatureSettings";
|
import ExerciseFeatureSettings from "@/components/ExerciseFeatureSettings";
|
||||||
import PageLayout from "@/components/PageLayout";
|
import PageLayout from "@/components/PageLayout";
|
||||||
import { useAuth } from "@/context/AuthContext";
|
import { useAuth } from "@/context/AuthContext";
|
||||||
@@ -30,6 +31,7 @@ export default function SettingsPage() {
|
|||||||
<div className="space-y-8">
|
<div className="space-y-8">
|
||||||
<BrandingSettings />
|
<BrandingSettings />
|
||||||
<EmailSettings />
|
<EmailSettings />
|
||||||
|
<EmailTemplates />
|
||||||
<ExerciseFeatureSettings />
|
<ExerciseFeatureSettings />
|
||||||
</div>
|
</div>
|
||||||
</PageLayout>
|
</PageLayout>
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ type EditableService = {
|
|||||||
id?: string;
|
id?: string;
|
||||||
display_name: string;
|
display_name: string;
|
||||||
provider_key: string;
|
provider_key: string;
|
||||||
smtp_enabled: boolean;
|
is_enabled: boolean;
|
||||||
is_default: boolean;
|
is_default: boolean;
|
||||||
smtp_host: string;
|
smtp_host: string;
|
||||||
smtp_port: number;
|
smtp_port: number;
|
||||||
@@ -26,7 +26,7 @@ function toEditable(service: OrganizationEmailService): EditableService {
|
|||||||
id: service.id,
|
id: service.id,
|
||||||
display_name: service.display_name,
|
display_name: service.display_name,
|
||||||
provider_key: service.provider_key,
|
provider_key: service.provider_key,
|
||||||
smtp_enabled: service.smtp_enabled,
|
is_enabled: service.is_enabled,
|
||||||
is_default: service.is_default,
|
is_default: service.is_default,
|
||||||
smtp_host: service.smtp_host || "",
|
smtp_host: service.smtp_host || "",
|
||||||
smtp_port: service.smtp_port || 587,
|
smtp_port: service.smtp_port || 587,
|
||||||
@@ -41,7 +41,7 @@ function newServiceTemplate(): EditableService {
|
|||||||
return {
|
return {
|
||||||
display_name: "Nuevo servicio SMTP",
|
display_name: "Nuevo servicio SMTP",
|
||||||
provider_key: "custom",
|
provider_key: "custom",
|
||||||
smtp_enabled: true,
|
is_enabled: true,
|
||||||
is_default: false,
|
is_default: false,
|
||||||
smtp_host: "",
|
smtp_host: "",
|
||||||
smtp_port: 587,
|
smtp_port: 587,
|
||||||
@@ -131,7 +131,7 @@ export default function EmailSettings() {
|
|||||||
service_type: "smtp",
|
service_type: "smtp",
|
||||||
provider_key: (form.provider_key || "custom").trim().toLowerCase(),
|
provider_key: (form.provider_key || "custom").trim().toLowerCase(),
|
||||||
display_name: form.display_name.trim() || "Servicio SMTP",
|
display_name: form.display_name.trim() || "Servicio SMTP",
|
||||||
smtp_enabled: form.smtp_enabled,
|
is_enabled: form.is_enabled,
|
||||||
is_default: form.is_default,
|
is_default: form.is_default,
|
||||||
smtp_host: form.smtp_host.trim() || undefined,
|
smtp_host: form.smtp_host.trim() || undefined,
|
||||||
smtp_port: Number(form.smtp_port) || 587,
|
smtp_port: Number(form.smtp_port) || 587,
|
||||||
@@ -186,7 +186,7 @@ export default function EmailSettings() {
|
|||||||
<div>
|
<div>
|
||||||
<h3 className="text-sm font-bold text-slate-900 dark:text-white">{svc.display_name}</h3>
|
<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">
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
{svc.is_default && (
|
{svc.is_default && (
|
||||||
@@ -271,8 +271,8 @@ export default function EmailSettings() {
|
|||||||
<label className="flex items-center gap-3 mt-6">
|
<label className="flex items-center gap-3 mt-6">
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={form.smtp_enabled}
|
checked={form.is_enabled}
|
||||||
onChange={(e) => setField("smtp_enabled", e.target.checked)}
|
onChange={(e) => setField("is_enabled", e.target.checked)}
|
||||||
/>
|
/>
|
||||||
<span className="text-sm text-slate-800 dark:text-gray-200">Habilitado</span>
|
<span className="text-sm text-slate-800 dark:text-gray-200">Habilitado</span>
|
||||||
</label>
|
</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -308,7 +308,7 @@ export interface OrganizationEmailService {
|
|||||||
service_type: string;
|
service_type: string;
|
||||||
provider_key: string;
|
provider_key: string;
|
||||||
display_name: string;
|
display_name: string;
|
||||||
smtp_enabled: boolean;
|
is_enabled: boolean;
|
||||||
is_default: boolean;
|
is_default: boolean;
|
||||||
smtp_host?: string;
|
smtp_host?: string;
|
||||||
smtp_port: number;
|
smtp_port: number;
|
||||||
@@ -322,7 +322,7 @@ export interface UpsertOrganizationEmailServicePayload {
|
|||||||
service_type: string;
|
service_type: string;
|
||||||
provider_key: string;
|
provider_key: string;
|
||||||
display_name: string;
|
display_name: string;
|
||||||
smtp_enabled: boolean;
|
is_enabled: boolean;
|
||||||
is_default: boolean;
|
is_default: boolean;
|
||||||
smtp_host?: string;
|
smtp_host?: string;
|
||||||
smtp_port: number;
|
smtp_port: number;
|
||||||
@@ -332,6 +332,28 @@ export interface UpsertOrganizationEmailServicePayload {
|
|||||||
smtp_starttls: boolean;
|
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 {
|
export interface ProvisionPayload {
|
||||||
org_name: string;
|
org_name: string;
|
||||||
org_domain?: string;
|
org_domain?: string;
|
||||||
@@ -923,6 +945,13 @@ export const cmsApi = {
|
|||||||
apiFetch(`/organization/email-services/${id}`, { method: 'DELETE' }),
|
apiFetch(`/organization/email-services/${id}`, { method: 'DELETE' }),
|
||||||
selectOrganizationEmailService: (id: string): Promise<void> =>
|
selectOrganizationEmailService: (id: string): Promise<void> =>
|
||||||
apiFetch(`/organization/email-services/${id}/select`, { method: 'POST' }),
|
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'),
|
getOrganizationExerciseSettings: (): Promise<OrganizationExerciseSettings> => apiFetch('/organization/exercise-settings'),
|
||||||
updateOrganizationExerciseSettings: (payload: Omit<OrganizationExerciseSettings, 'organization_id'>): Promise<OrganizationExerciseSettings> =>
|
updateOrganizationExerciseSettings: (payload: Omit<OrganizationExerciseSettings, 'organization_id'>): Promise<OrganizationExerciseSettings> =>
|
||||||
apiFetch('/organization/exercise-settings', { method: 'PUT', body: JSON.stringify(payload) }),
|
apiFetch('/organization/exercise-settings', { method: 'PUT', body: JSON.stringify(payload) }),
|
||||||
|
|||||||