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
+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) }),