feat: add email settings management
- Introduced EmailSettings component for managing SMTP services. - Added API endpoints for organization email services including CRUD operations. - Created database migrations for organization_email_settings and organization_email_services tables. - Updated the settings page to include EmailSettings component. - Implemented validation and error handling for email service operations.
This commit is contained in:
Generated
+69
@@ -49,6 +49,17 @@ version = "1.0.100"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61"
|
checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "async-trait"
|
||||||
|
version = "0.1.89"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "atoi"
|
name = "atoi"
|
||||||
version = "2.0.0"
|
version = "2.0.0"
|
||||||
@@ -1244,6 +1255,22 @@ dependencies = [
|
|||||||
"zeroize",
|
"zeroize",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "email-encoding"
|
||||||
|
version = "0.4.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "9298e6504d9b9e780ed3f7dfd43a61be8cd0e09eb07f7706a945b0072b6670b6"
|
||||||
|
dependencies = [
|
||||||
|
"base64 0.22.1",
|
||||||
|
"memchr",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "email_address"
|
||||||
|
version = "0.2.9"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e079f19b08ca6239f47f8ba8509c11cf3ea30095831f7fed61441475edd8c449"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "encoding_rs"
|
name = "encoding_rs"
|
||||||
version = "0.8.35"
|
version = "0.8.35"
|
||||||
@@ -2111,6 +2138,32 @@ dependencies = [
|
|||||||
"spin",
|
"spin",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "lettre"
|
||||||
|
version = "0.11.21"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "dabda5859ee7c06b995b9d1165aa52c39110e079ef609db97178d86aeb051fa7"
|
||||||
|
dependencies = [
|
||||||
|
"async-trait",
|
||||||
|
"base64 0.22.1",
|
||||||
|
"email-encoding",
|
||||||
|
"email_address",
|
||||||
|
"fastrand",
|
||||||
|
"futures-io",
|
||||||
|
"futures-util",
|
||||||
|
"httpdate",
|
||||||
|
"idna",
|
||||||
|
"mime",
|
||||||
|
"native-tls",
|
||||||
|
"nom",
|
||||||
|
"percent-encoding",
|
||||||
|
"quoted_printable",
|
||||||
|
"socket2 0.6.1",
|
||||||
|
"tokio",
|
||||||
|
"tokio-native-tls",
|
||||||
|
"url",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "libc"
|
name = "libc"
|
||||||
version = "0.2.178"
|
version = "0.2.178"
|
||||||
@@ -2170,6 +2223,7 @@ dependencies = [
|
|||||||
"dotenvy",
|
"dotenvy",
|
||||||
"http 1.4.0",
|
"http 1.4.0",
|
||||||
"jsonwebtoken",
|
"jsonwebtoken",
|
||||||
|
"lettre",
|
||||||
"mime_guess",
|
"mime_guess",
|
||||||
"reqwest 0.12.26",
|
"reqwest 0.12.26",
|
||||||
"serde",
|
"serde",
|
||||||
@@ -2319,6 +2373,15 @@ version = "0.4.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "b93853da6d84c2e3c7d730d6473e8817692dd89be387eb01b94d7f108ecb5b8c"
|
checksum = "b93853da6d84c2e3c7d730d6473e8817692dd89be387eb01b94d7f108ecb5b8c"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "nom"
|
||||||
|
version = "8.0.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "df9761775871bdef83bee530e60050f7e54b1105350d6884eb0fb4f46c2f9405"
|
||||||
|
dependencies = [
|
||||||
|
"memchr",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "nonempty"
|
name = "nonempty"
|
||||||
version = "0.7.0"
|
version = "0.7.0"
|
||||||
@@ -2778,6 +2841,12 @@ dependencies = [
|
|||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "quoted_printable"
|
||||||
|
version = "0.5.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "478e0585659a122aa407eb7e3c0e1fa51b1d8a870038bd29f0cf4a8551eea972"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "r-efi"
|
name = "r-efi"
|
||||||
version = "5.3.0"
|
version = "5.3.0"
|
||||||
|
|||||||
@@ -65,6 +65,22 @@ services:
|
|||||||
timeout: 5s
|
timeout: 5s
|
||||||
retries: 5
|
retries: 5
|
||||||
|
|
||||||
|
# ========================================
|
||||||
|
# SMTP Relay Interno (Mailpit)
|
||||||
|
# ========================================
|
||||||
|
mailpit:
|
||||||
|
image: axllent/mailpit:latest
|
||||||
|
container_name: openccb-mailpit
|
||||||
|
# SMTP para servicios internos y UI solo accesible por SSH túnel/localhost
|
||||||
|
ports:
|
||||||
|
- "127.0.0.1:8025:8025"
|
||||||
|
environment:
|
||||||
|
- MP_SMTP_BIND_ADDR=0.0.0.0:1025
|
||||||
|
- MP_UI_BIND_ADDR=0.0.0.0:8025
|
||||||
|
networks:
|
||||||
|
- openccb-network
|
||||||
|
restart: always
|
||||||
|
|
||||||
# ========================================
|
# ========================================
|
||||||
# Studio + CMS (HTTPS)
|
# Studio + CMS (HTTPS)
|
||||||
# ========================================
|
# ========================================
|
||||||
@@ -126,6 +142,12 @@ services:
|
|||||||
- HOSTNAME=0.0.0.0
|
- HOSTNAME=0.0.0.0
|
||||||
- DATABASE_URL=${LMS_DATABASE_URL}
|
- DATABASE_URL=${LMS_DATABASE_URL}
|
||||||
- NEXT_PUBLIC_CMS_API_URL=${NEXT_PUBLIC_CMS_API_URL}
|
- NEXT_PUBLIC_CMS_API_URL=${NEXT_PUBLIC_CMS_API_URL}
|
||||||
|
- SMTP_ENABLED=${SMTP_ENABLED:-false}
|
||||||
|
- SMTP_HOST=${SMTP_HOST:-mailpit}
|
||||||
|
- SMTP_PORT=${SMTP_PORT:-1025}
|
||||||
|
- SMTP_FROM=${SMTP_FROM:-OpenCCB <no-reply@norteamericano.com>}
|
||||||
|
- SMTP_USERNAME=${SMTP_USERNAME:-}
|
||||||
|
- SMTP_PASSWORD=${SMTP_PASSWORD:-}
|
||||||
env_file: .env
|
env_file: .env
|
||||||
extra_hosts:
|
extra_hosts:
|
||||||
- "host.docker.internal:host-gateway"
|
- "host.docker.internal:host-gateway"
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
CREATE TABLE IF NOT EXISTS organization_email_settings (
|
||||||
|
organization_id UUID PRIMARY KEY REFERENCES organizations(id) ON DELETE CASCADE,
|
||||||
|
smtp_enabled BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
smtp_host TEXT,
|
||||||
|
smtp_port INTEGER NOT NULL DEFAULT 587,
|
||||||
|
smtp_from TEXT,
|
||||||
|
smtp_username TEXT,
|
||||||
|
smtp_password TEXT,
|
||||||
|
smtp_starttls BOOLEAN NOT NULL DEFAULT TRUE,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_org_email_settings_org_id ON organization_email_settings(organization_id);
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
CREATE TABLE IF NOT EXISTS organization_email_services (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
organization_id UUID NOT NULL REFERENCES organizations(id) ON DELETE CASCADE,
|
||||||
|
service_type TEXT NOT NULL DEFAULT 'smtp',
|
||||||
|
provider_key TEXT NOT NULL DEFAULT 'custom',
|
||||||
|
display_name TEXT NOT NULL DEFAULT 'SMTP principal',
|
||||||
|
is_enabled BOOLEAN NOT NULL DEFAULT TRUE,
|
||||||
|
is_default BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
smtp_host TEXT,
|
||||||
|
smtp_port INTEGER NOT NULL DEFAULT 587,
|
||||||
|
smtp_from TEXT,
|
||||||
|
smtp_username TEXT,
|
||||||
|
smtp_password TEXT,
|
||||||
|
smtp_starttls BOOLEAN NOT NULL DEFAULT TRUE,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_org_email_services_org_id ON organization_email_services(organization_id);
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS uq_org_email_services_default_per_org
|
||||||
|
ON organization_email_services(organization_id)
|
||||||
|
WHERE is_default = TRUE;
|
||||||
|
|
||||||
|
INSERT INTO organization_email_services (
|
||||||
|
organization_id,
|
||||||
|
service_type,
|
||||||
|
provider_key,
|
||||||
|
display_name,
|
||||||
|
is_enabled,
|
||||||
|
is_default,
|
||||||
|
smtp_host,
|
||||||
|
smtp_port,
|
||||||
|
smtp_from,
|
||||||
|
smtp_username,
|
||||||
|
smtp_password,
|
||||||
|
smtp_starttls,
|
||||||
|
created_at,
|
||||||
|
updated_at
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
s.organization_id,
|
||||||
|
'smtp',
|
||||||
|
'custom',
|
||||||
|
'SMTP principal',
|
||||||
|
s.smtp_enabled,
|
||||||
|
TRUE,
|
||||||
|
s.smtp_host,
|
||||||
|
s.smtp_port,
|
||||||
|
s.smtp_from,
|
||||||
|
s.smtp_username,
|
||||||
|
s.smtp_password,
|
||||||
|
s.smtp_starttls,
|
||||||
|
NOW(),
|
||||||
|
NOW()
|
||||||
|
FROM organization_email_settings s
|
||||||
|
LEFT JOIN organization_email_services es
|
||||||
|
ON es.organization_id = s.organization_id
|
||||||
|
WHERE es.id IS NULL;
|
||||||
@@ -0,0 +1,875 @@
|
|||||||
|
use axum::{
|
||||||
|
Json,
|
||||||
|
extract::{Path, State},
|
||||||
|
http::StatusCode,
|
||||||
|
};
|
||||||
|
use common::auth::Claims;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use serde_json::json;
|
||||||
|
use sqlx::PgPool;
|
||||||
|
use std::env;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use super::handlers::{Org, log_action};
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, sqlx::FromRow)]
|
||||||
|
struct OrganizationEmailServiceRow {
|
||||||
|
id: Uuid,
|
||||||
|
organization_id: Uuid,
|
||||||
|
service_type: String,
|
||||||
|
provider_key: String,
|
||||||
|
display_name: String,
|
||||||
|
is_enabled: bool,
|
||||||
|
is_default: bool,
|
||||||
|
smtp_host: Option<String>,
|
||||||
|
smtp_port: i32,
|
||||||
|
smtp_from: Option<String>,
|
||||||
|
smtp_username: Option<String>,
|
||||||
|
smtp_password: Option<String>,
|
||||||
|
smtp_starttls: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||||
|
pub struct OrganizationEmailServiceResponse {
|
||||||
|
pub id: Uuid,
|
||||||
|
pub organization_id: Uuid,
|
||||||
|
pub service_type: String,
|
||||||
|
pub provider_key: String,
|
||||||
|
pub display_name: String,
|
||||||
|
pub is_enabled: bool,
|
||||||
|
pub is_default: bool,
|
||||||
|
pub smtp_host: Option<String>,
|
||||||
|
pub smtp_port: i32,
|
||||||
|
pub smtp_from: Option<String>,
|
||||||
|
pub smtp_username: Option<String>,
|
||||||
|
pub smtp_starttls: bool,
|
||||||
|
pub has_password: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||||
|
pub struct UpsertOrganizationEmailServicePayload {
|
||||||
|
pub service_type: String,
|
||||||
|
pub provider_key: String,
|
||||||
|
pub display_name: String,
|
||||||
|
pub is_enabled: bool,
|
||||||
|
pub is_default: bool,
|
||||||
|
pub smtp_host: Option<String>,
|
||||||
|
pub smtp_port: i32,
|
||||||
|
pub smtp_from: Option<String>,
|
||||||
|
pub smtp_username: Option<String>,
|
||||||
|
pub smtp_password: Option<String>,
|
||||||
|
pub smtp_starttls: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_bool(value: &str) -> bool {
|
||||||
|
let normalized = value.trim().to_lowercase();
|
||||||
|
normalized == "1" || normalized == "true" || normalized == "yes"
|
||||||
|
}
|
||||||
|
|
||||||
|
fn normalize_optional(value: Option<String>) -> Option<String> {
|
||||||
|
value.and_then(|v| {
|
||||||
|
let trimmed = v.trim().to_string();
|
||||||
|
if trimmed.is_empty() {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some(trimmed)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn validate_payload(payload: &UpsertOrganizationEmailServicePayload) -> Result<(), (StatusCode, String)> {
|
||||||
|
if payload.smtp_port <= 0 || payload.smtp_port > 65535 {
|
||||||
|
return Err((
|
||||||
|
StatusCode::BAD_REQUEST,
|
||||||
|
"smtp_port debe estar entre 1 y 65535".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
if payload.service_type.trim().to_lowercase() != "smtp" {
|
||||||
|
return Err((
|
||||||
|
StatusCode::BAD_REQUEST,
|
||||||
|
"Por ahora solo se soporta service_type='smtp'".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
if payload.is_enabled {
|
||||||
|
let host_ok = payload
|
||||||
|
.smtp_host
|
||||||
|
.as_ref()
|
||||||
|
.map(|v| !v.trim().is_empty())
|
||||||
|
.unwrap_or(false);
|
||||||
|
let from_ok = payload
|
||||||
|
.smtp_from
|
||||||
|
.as_ref()
|
||||||
|
.map(|v| !v.trim().is_empty())
|
||||||
|
.unwrap_or(false);
|
||||||
|
|
||||||
|
if !host_ok {
|
||||||
|
return Err((
|
||||||
|
StatusCode::BAD_REQUEST,
|
||||||
|
"smtp_host es requerido cuando el servicio está habilitado".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
if !from_ok {
|
||||||
|
return Err((
|
||||||
|
StatusCode::BAD_REQUEST,
|
||||||
|
"smtp_from es requerido cuando el servicio está habilitado".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn to_response(row: OrganizationEmailServiceRow) -> OrganizationEmailServiceResponse {
|
||||||
|
OrganizationEmailServiceResponse {
|
||||||
|
id: row.id,
|
||||||
|
organization_id: row.organization_id,
|
||||||
|
service_type: row.service_type,
|
||||||
|
provider_key: row.provider_key,
|
||||||
|
display_name: row.display_name,
|
||||||
|
is_enabled: row.is_enabled,
|
||||||
|
is_default: row.is_default,
|
||||||
|
smtp_host: row.smtp_host,
|
||||||
|
smtp_port: row.smtp_port,
|
||||||
|
smtp_from: row.smtp_from,
|
||||||
|
smtp_username: row.smtp_username,
|
||||||
|
smtp_starttls: row.smtp_starttls,
|
||||||
|
has_password: row
|
||||||
|
.smtp_password
|
||||||
|
.as_ref()
|
||||||
|
.map(|p| !p.trim().is_empty())
|
||||||
|
.unwrap_or(false),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn ensure_bootstrap_service(pool: &PgPool, organization_id: Uuid) -> Result<(), (StatusCode, String)> {
|
||||||
|
let count: i64 = sqlx::query_scalar(
|
||||||
|
"SELECT COUNT(*) FROM organization_email_services WHERE organization_id = $1",
|
||||||
|
)
|
||||||
|
.bind(organization_id)
|
||||||
|
.fetch_one(pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e| {
|
||||||
|
(
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
format!("Error al verificar servicios de email: {}", e),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
|
||||||
|
if count > 0 {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
let enabled = env::var("SMTP_ENABLED")
|
||||||
|
.map(|v| parse_bool(&v))
|
||||||
|
.unwrap_or(false);
|
||||||
|
let host = normalize_optional(env::var("SMTP_HOST").ok());
|
||||||
|
let port = env::var("SMTP_PORT")
|
||||||
|
.ok()
|
||||||
|
.and_then(|v| v.parse::<i32>().ok())
|
||||||
|
.filter(|v| *v > 0 && *v <= 65535)
|
||||||
|
.unwrap_or(587);
|
||||||
|
let from = normalize_optional(env::var("SMTP_FROM").ok());
|
||||||
|
let username = normalize_optional(env::var("SMTP_USERNAME").ok());
|
||||||
|
let password = normalize_optional(env::var("SMTP_PASSWORD").ok());
|
||||||
|
let starttls = env::var("SMTP_STARTTLS")
|
||||||
|
.map(|v| parse_bool(&v))
|
||||||
|
.unwrap_or(true);
|
||||||
|
let provider_key = env::var("EMAIL_PROVIDER")
|
||||||
|
.ok()
|
||||||
|
.filter(|v| !v.trim().is_empty())
|
||||||
|
.unwrap_or_else(|| "custom".to_string());
|
||||||
|
let display_name = env::var("EMAIL_SERVICE_NAME")
|
||||||
|
.ok()
|
||||||
|
.filter(|v| !v.trim().is_empty())
|
||||||
|
.unwrap_or_else(|| format!("SMTP {}", provider_key.to_uppercase()));
|
||||||
|
|
||||||
|
sqlx::query(
|
||||||
|
r#"
|
||||||
|
INSERT INTO organization_email_services (
|
||||||
|
organization_id,
|
||||||
|
service_type,
|
||||||
|
provider_key,
|
||||||
|
display_name,
|
||||||
|
is_enabled,
|
||||||
|
is_default,
|
||||||
|
smtp_host,
|
||||||
|
smtp_port,
|
||||||
|
smtp_from,
|
||||||
|
smtp_username,
|
||||||
|
smtp_password,
|
||||||
|
smtp_starttls,
|
||||||
|
updated_at
|
||||||
|
)
|
||||||
|
VALUES ($1, 'smtp', $2, $3, $4, TRUE, $5, $6, $7, $8, $9, $10, NOW())
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(organization_id)
|
||||||
|
.bind(provider_key)
|
||||||
|
.bind(display_name)
|
||||||
|
.bind(enabled)
|
||||||
|
.bind(host)
|
||||||
|
.bind(port)
|
||||||
|
.bind(from)
|
||||||
|
.bind(username)
|
||||||
|
.bind(password)
|
||||||
|
.bind(starttls)
|
||||||
|
.execute(pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e| {
|
||||||
|
(
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
format!("Error al bootstrapear servicio SMTP desde entorno: {}", e),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn select_default_service(
|
||||||
|
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
|
||||||
|
organization_id: Uuid,
|
||||||
|
service_id: Uuid,
|
||||||
|
) -> Result<(), sqlx::Error> {
|
||||||
|
sqlx::query(
|
||||||
|
"UPDATE organization_email_services SET is_default = FALSE WHERE organization_id = $1",
|
||||||
|
)
|
||||||
|
.bind(organization_id)
|
||||||
|
.execute(&mut **tx)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
sqlx::query(
|
||||||
|
"UPDATE organization_email_services SET is_default = TRUE, updated_at = NOW() WHERE id = $1 AND organization_id = $2",
|
||||||
|
)
|
||||||
|
.bind(service_id)
|
||||||
|
.bind(organization_id)
|
||||||
|
.execute(&mut **tx)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn list_organization_email_services(
|
||||||
|
claims: Claims,
|
||||||
|
Org(org_ctx): Org,
|
||||||
|
State(pool): State<PgPool>,
|
||||||
|
) -> Result<Json<Vec<OrganizationEmailServiceResponse>>, (StatusCode, String)> {
|
||||||
|
if claims.role != "admin" {
|
||||||
|
return Err((StatusCode::FORBIDDEN, "Se requiere acceso de administrador".into()));
|
||||||
|
}
|
||||||
|
|
||||||
|
ensure_bootstrap_service(&pool, org_ctx.id).await?;
|
||||||
|
|
||||||
|
let rows = sqlx::query_as::<_, OrganizationEmailServiceRow>(
|
||||||
|
r#"
|
||||||
|
SELECT
|
||||||
|
id,
|
||||||
|
organization_id,
|
||||||
|
service_type,
|
||||||
|
provider_key,
|
||||||
|
display_name,
|
||||||
|
is_enabled,
|
||||||
|
is_default,
|
||||||
|
smtp_host,
|
||||||
|
smtp_port,
|
||||||
|
smtp_from,
|
||||||
|
smtp_username,
|
||||||
|
smtp_password,
|
||||||
|
smtp_starttls
|
||||||
|
FROM organization_email_services
|
||||||
|
WHERE organization_id = $1
|
||||||
|
ORDER BY is_default DESC, created_at ASC
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(org_ctx.id)
|
||||||
|
.fetch_all(&pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e| {
|
||||||
|
(
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
format!("Error al cargar servicios de email: {}", e),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
|
||||||
|
Ok(Json(rows.into_iter().map(to_response).collect()))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn create_organization_email_service(
|
||||||
|
claims: Claims,
|
||||||
|
Org(org_ctx): Org,
|
||||||
|
State(pool): State<PgPool>,
|
||||||
|
Json(payload): Json<UpsertOrganizationEmailServicePayload>,
|
||||||
|
) -> Result<Json<OrganizationEmailServiceResponse>, (StatusCode, String)> {
|
||||||
|
if claims.role != "admin" {
|
||||||
|
return Err((StatusCode::FORBIDDEN, "Se requiere acceso de administrador".into()));
|
||||||
|
}
|
||||||
|
|
||||||
|
validate_payload(&payload)?;
|
||||||
|
|
||||||
|
let service_type = payload.service_type.trim().to_lowercase();
|
||||||
|
let provider_key = payload.provider_key.trim().to_lowercase();
|
||||||
|
let display_name = payload.display_name.trim().to_string();
|
||||||
|
let smtp_host = normalize_optional(payload.smtp_host);
|
||||||
|
let smtp_from = normalize_optional(payload.smtp_from);
|
||||||
|
let smtp_username = normalize_optional(payload.smtp_username);
|
||||||
|
let smtp_password = normalize_optional(payload.smtp_password);
|
||||||
|
|
||||||
|
let mut tx = pool.begin().await.map_err(|e| {
|
||||||
|
(
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
format!("Error al abrir transacción: {}", e),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let existing_count: i64 = sqlx::query_scalar(
|
||||||
|
"SELECT COUNT(*) FROM organization_email_services WHERE organization_id = $1",
|
||||||
|
)
|
||||||
|
.bind(org_ctx.id)
|
||||||
|
.fetch_one(&mut *tx)
|
||||||
|
.await
|
||||||
|
.map_err(|e| {
|
||||||
|
(
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
format!("Error al contar servicios de email: {}", e),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let make_default = payload.is_default || existing_count == 0;
|
||||||
|
|
||||||
|
let row = sqlx::query_as::<_, OrganizationEmailServiceRow>(
|
||||||
|
r#"
|
||||||
|
INSERT INTO organization_email_services (
|
||||||
|
organization_id,
|
||||||
|
service_type,
|
||||||
|
provider_key,
|
||||||
|
display_name,
|
||||||
|
is_enabled,
|
||||||
|
is_default,
|
||||||
|
smtp_host,
|
||||||
|
smtp_port,
|
||||||
|
smtp_from,
|
||||||
|
smtp_username,
|
||||||
|
smtp_password,
|
||||||
|
smtp_starttls,
|
||||||
|
updated_at
|
||||||
|
)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, FALSE, $6, $7, $8, $9, $10, $11, NOW())
|
||||||
|
RETURNING
|
||||||
|
id,
|
||||||
|
organization_id,
|
||||||
|
service_type,
|
||||||
|
provider_key,
|
||||||
|
display_name,
|
||||||
|
is_enabled,
|
||||||
|
is_default,
|
||||||
|
smtp_host,
|
||||||
|
smtp_port,
|
||||||
|
smtp_from,
|
||||||
|
smtp_username,
|
||||||
|
smtp_password,
|
||||||
|
smtp_starttls
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(org_ctx.id)
|
||||||
|
.bind(service_type)
|
||||||
|
.bind(provider_key)
|
||||||
|
.bind(display_name)
|
||||||
|
.bind(payload.is_enabled)
|
||||||
|
.bind(smtp_host)
|
||||||
|
.bind(payload.smtp_port)
|
||||||
|
.bind(smtp_from)
|
||||||
|
.bind(smtp_username)
|
||||||
|
.bind(smtp_password)
|
||||||
|
.bind(payload.smtp_starttls)
|
||||||
|
.fetch_one(&mut *tx)
|
||||||
|
.await
|
||||||
|
.map_err(|e| {
|
||||||
|
(
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
format!("Error al crear servicio de email: {}", e),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
|
||||||
|
if make_default {
|
||||||
|
select_default_service(&mut tx, org_ctx.id, row.id)
|
||||||
|
.await
|
||||||
|
.map_err(|e| {
|
||||||
|
(
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
format!("Error al establecer servicio por defecto: {}", e),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
}
|
||||||
|
|
||||||
|
let row = sqlx::query_as::<_, OrganizationEmailServiceRow>(
|
||||||
|
r#"
|
||||||
|
SELECT
|
||||||
|
id,
|
||||||
|
organization_id,
|
||||||
|
service_type,
|
||||||
|
provider_key,
|
||||||
|
display_name,
|
||||||
|
is_enabled,
|
||||||
|
is_default,
|
||||||
|
smtp_host,
|
||||||
|
smtp_port,
|
||||||
|
smtp_from,
|
||||||
|
smtp_username,
|
||||||
|
smtp_password,
|
||||||
|
smtp_starttls
|
||||||
|
FROM organization_email_services
|
||||||
|
WHERE id = $1 AND organization_id = $2
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(row.id)
|
||||||
|
.bind(org_ctx.id)
|
||||||
|
.fetch_one(&mut *tx)
|
||||||
|
.await
|
||||||
|
.map_err(|e| {
|
||||||
|
(
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
format!("Error al recargar servicio de email: {}", e),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
|
||||||
|
tx.commit().await.map_err(|e| {
|
||||||
|
(
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
format!("Error al confirmar transacción: {}", e),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
|
||||||
|
log_action(
|
||||||
|
&pool,
|
||||||
|
claims.org,
|
||||||
|
claims.sub,
|
||||||
|
"CREATE_EMAIL_SERVICE",
|
||||||
|
"Organization",
|
||||||
|
org_ctx.id,
|
||||||
|
json!({
|
||||||
|
"service_id": row.id,
|
||||||
|
"service_type": row.service_type,
|
||||||
|
"provider_key": row.provider_key,
|
||||||
|
"display_name": row.display_name,
|
||||||
|
"is_enabled": row.is_enabled,
|
||||||
|
"is_default": row.is_default,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
Ok(Json(to_response(row)))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn update_organization_email_service(
|
||||||
|
claims: Claims,
|
||||||
|
Org(org_ctx): Org,
|
||||||
|
State(pool): State<PgPool>,
|
||||||
|
Path(id): Path<Uuid>,
|
||||||
|
Json(payload): Json<UpsertOrganizationEmailServicePayload>,
|
||||||
|
) -> Result<Json<OrganizationEmailServiceResponse>, (StatusCode, String)> {
|
||||||
|
if claims.role != "admin" {
|
||||||
|
return Err((StatusCode::FORBIDDEN, "Se requiere acceso de administrador".into()));
|
||||||
|
}
|
||||||
|
|
||||||
|
validate_payload(&payload)?;
|
||||||
|
|
||||||
|
let service_type = payload.service_type.trim().to_lowercase();
|
||||||
|
let provider_key = payload.provider_key.trim().to_lowercase();
|
||||||
|
let display_name = payload.display_name.trim().to_string();
|
||||||
|
let smtp_host = normalize_optional(payload.smtp_host);
|
||||||
|
let smtp_from = normalize_optional(payload.smtp_from);
|
||||||
|
let smtp_username = normalize_optional(payload.smtp_username);
|
||||||
|
let smtp_password = normalize_optional(payload.smtp_password);
|
||||||
|
|
||||||
|
let mut tx = pool.begin().await.map_err(|e| {
|
||||||
|
(
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
format!("Error al abrir transacción: {}", e),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let row = sqlx::query_as::<_, OrganizationEmailServiceRow>(
|
||||||
|
r#"
|
||||||
|
UPDATE organization_email_services
|
||||||
|
SET
|
||||||
|
service_type = $3,
|
||||||
|
provider_key = $4,
|
||||||
|
display_name = $5,
|
||||||
|
is_enabled = $6,
|
||||||
|
smtp_host = $7,
|
||||||
|
smtp_port = $8,
|
||||||
|
smtp_from = $9,
|
||||||
|
smtp_username = $10,
|
||||||
|
smtp_password = CASE
|
||||||
|
WHEN $11 IS NULL OR btrim($11) = '' THEN smtp_password
|
||||||
|
ELSE $11
|
||||||
|
END,
|
||||||
|
smtp_starttls = $12,
|
||||||
|
updated_at = NOW()
|
||||||
|
WHERE id = $1 AND organization_id = $2
|
||||||
|
RETURNING
|
||||||
|
id,
|
||||||
|
organization_id,
|
||||||
|
service_type,
|
||||||
|
provider_key,
|
||||||
|
display_name,
|
||||||
|
is_enabled,
|
||||||
|
is_default,
|
||||||
|
smtp_host,
|
||||||
|
smtp_port,
|
||||||
|
smtp_from,
|
||||||
|
smtp_username,
|
||||||
|
smtp_password,
|
||||||
|
smtp_starttls
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(id)
|
||||||
|
.bind(org_ctx.id)
|
||||||
|
.bind(service_type)
|
||||||
|
.bind(provider_key)
|
||||||
|
.bind(display_name)
|
||||||
|
.bind(payload.is_enabled)
|
||||||
|
.bind(smtp_host)
|
||||||
|
.bind(payload.smtp_port)
|
||||||
|
.bind(smtp_from)
|
||||||
|
.bind(smtp_username)
|
||||||
|
.bind(smtp_password)
|
||||||
|
.bind(payload.smtp_starttls)
|
||||||
|
.fetch_optional(&mut *tx)
|
||||||
|
.await
|
||||||
|
.map_err(|e| {
|
||||||
|
(
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
format!("Error al actualizar servicio de email: {}", e),
|
||||||
|
)
|
||||||
|
})?
|
||||||
|
.ok_or((StatusCode::NOT_FOUND, "Servicio no encontrado".to_string()))?;
|
||||||
|
|
||||||
|
if payload.is_default {
|
||||||
|
select_default_service(&mut tx, org_ctx.id, row.id)
|
||||||
|
.await
|
||||||
|
.map_err(|e| {
|
||||||
|
(
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
format!("Error al establecer servicio por defecto: {}", e),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
}
|
||||||
|
|
||||||
|
let row = sqlx::query_as::<_, OrganizationEmailServiceRow>(
|
||||||
|
r#"
|
||||||
|
SELECT
|
||||||
|
id,
|
||||||
|
organization_id,
|
||||||
|
service_type,
|
||||||
|
provider_key,
|
||||||
|
display_name,
|
||||||
|
is_enabled,
|
||||||
|
is_default,
|
||||||
|
smtp_host,
|
||||||
|
smtp_port,
|
||||||
|
smtp_from,
|
||||||
|
smtp_username,
|
||||||
|
smtp_password,
|
||||||
|
smtp_starttls
|
||||||
|
FROM organization_email_services
|
||||||
|
WHERE id = $1 AND organization_id = $2
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(row.id)
|
||||||
|
.bind(org_ctx.id)
|
||||||
|
.fetch_one(&mut *tx)
|
||||||
|
.await
|
||||||
|
.map_err(|e| {
|
||||||
|
(
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
format!("Error al recargar servicio de email: {}", e),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
|
||||||
|
tx.commit().await.map_err(|e| {
|
||||||
|
(
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
format!("Error al confirmar transacción: {}", e),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
|
||||||
|
log_action(
|
||||||
|
&pool,
|
||||||
|
claims.org,
|
||||||
|
claims.sub,
|
||||||
|
"UPDATE_EMAIL_SERVICE",
|
||||||
|
"Organization",
|
||||||
|
org_ctx.id,
|
||||||
|
json!({
|
||||||
|
"service_id": row.id,
|
||||||
|
"service_type": row.service_type,
|
||||||
|
"provider_key": row.provider_key,
|
||||||
|
"display_name": row.display_name,
|
||||||
|
"is_enabled": row.is_enabled,
|
||||||
|
"is_default": row.is_default,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
Ok(Json(to_response(row)))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn delete_organization_email_service(
|
||||||
|
claims: Claims,
|
||||||
|
Org(org_ctx): Org,
|
||||||
|
State(pool): State<PgPool>,
|
||||||
|
Path(id): Path<Uuid>,
|
||||||
|
) -> Result<StatusCode, (StatusCode, String)> {
|
||||||
|
if claims.role != "admin" {
|
||||||
|
return Err((StatusCode::FORBIDDEN, "Se requiere acceso de administrador".into()));
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut tx = pool.begin().await.map_err(|e| {
|
||||||
|
(
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
format!("Error al abrir transacción: {}", e),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let existing = sqlx::query_as::<_, (Uuid, bool)>(
|
||||||
|
"SELECT id, is_default FROM organization_email_services WHERE id = $1 AND organization_id = $2",
|
||||||
|
)
|
||||||
|
.bind(id)
|
||||||
|
.bind(org_ctx.id)
|
||||||
|
.fetch_optional(&mut *tx)
|
||||||
|
.await
|
||||||
|
.map_err(|e| {
|
||||||
|
(
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
format!("Error al verificar servicio: {}", e),
|
||||||
|
)
|
||||||
|
})?
|
||||||
|
.ok_or((StatusCode::NOT_FOUND, "Servicio no encontrado".to_string()))?;
|
||||||
|
|
||||||
|
let total: i64 = sqlx::query_scalar(
|
||||||
|
"SELECT COUNT(*) FROM organization_email_services WHERE organization_id = $1",
|
||||||
|
)
|
||||||
|
.bind(org_ctx.id)
|
||||||
|
.fetch_one(&mut *tx)
|
||||||
|
.await
|
||||||
|
.map_err(|e| {
|
||||||
|
(
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
format!("Error al contar servicios: {}", e),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
|
||||||
|
if total <= 1 {
|
||||||
|
return Err((
|
||||||
|
StatusCode::BAD_REQUEST,
|
||||||
|
"Debe existir al menos un servicio de email por organización".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
sqlx::query("DELETE FROM organization_email_services WHERE id = $1 AND organization_id = $2")
|
||||||
|
.bind(id)
|
||||||
|
.bind(org_ctx.id)
|
||||||
|
.execute(&mut *tx)
|
||||||
|
.await
|
||||||
|
.map_err(|e| {
|
||||||
|
(
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
format!("Error al eliminar servicio de email: {}", e),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
|
||||||
|
if existing.1 {
|
||||||
|
let replacement_id: Uuid = sqlx::query_scalar(
|
||||||
|
"SELECT id FROM organization_email_services WHERE organization_id = $1 ORDER BY created_at ASC LIMIT 1",
|
||||||
|
)
|
||||||
|
.bind(org_ctx.id)
|
||||||
|
.fetch_one(&mut *tx)
|
||||||
|
.await
|
||||||
|
.map_err(|e| {
|
||||||
|
(
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
format!("Error al seleccionar reemplazo por defecto: {}", e),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
|
||||||
|
select_default_service(&mut tx, org_ctx.id, replacement_id)
|
||||||
|
.await
|
||||||
|
.map_err(|e| {
|
||||||
|
(
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
format!("Error al actualizar servicio por defecto: {}", e),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
}
|
||||||
|
|
||||||
|
tx.commit().await.map_err(|e| {
|
||||||
|
(
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
format!("Error al confirmar transacción: {}", e),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
|
||||||
|
log_action(
|
||||||
|
&pool,
|
||||||
|
claims.org,
|
||||||
|
claims.sub,
|
||||||
|
"DELETE_EMAIL_SERVICE",
|
||||||
|
"Organization",
|
||||||
|
org_ctx.id,
|
||||||
|
json!({ "service_id": id }),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
Ok(StatusCode::NO_CONTENT)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn select_organization_email_service(
|
||||||
|
claims: Claims,
|
||||||
|
Org(org_ctx): Org,
|
||||||
|
State(pool): State<PgPool>,
|
||||||
|
Path(id): Path<Uuid>,
|
||||||
|
) -> Result<StatusCode, (StatusCode, String)> {
|
||||||
|
if claims.role != "admin" {
|
||||||
|
return Err((StatusCode::FORBIDDEN, "Se requiere acceso de administrador".into()));
|
||||||
|
}
|
||||||
|
|
||||||
|
let exists: bool = sqlx::query_scalar(
|
||||||
|
"SELECT EXISTS(SELECT 1 FROM organization_email_services WHERE id = $1 AND organization_id = $2)",
|
||||||
|
)
|
||||||
|
.bind(id)
|
||||||
|
.bind(org_ctx.id)
|
||||||
|
.fetch_one(&pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e| {
|
||||||
|
(
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
format!("Error al verificar servicio de email: {}", e),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
|
||||||
|
if !exists {
|
||||||
|
return Err((StatusCode::NOT_FOUND, "Servicio no encontrado".to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut tx = pool.begin().await.map_err(|e| {
|
||||||
|
(
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
format!("Error al abrir transacción: {}", e),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
|
||||||
|
select_default_service(&mut tx, org_ctx.id, id)
|
||||||
|
.await
|
||||||
|
.map_err(|e| {
|
||||||
|
(
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
format!("Error al seleccionar servicio por defecto: {}", e),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
|
||||||
|
tx.commit().await.map_err(|e| {
|
||||||
|
(
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
format!("Error al confirmar transacción: {}", e),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
|
||||||
|
log_action(
|
||||||
|
&pool,
|
||||||
|
claims.org,
|
||||||
|
claims.sub,
|
||||||
|
"SELECT_EMAIL_SERVICE",
|
||||||
|
"Organization",
|
||||||
|
org_ctx.id,
|
||||||
|
json!({ "service_id": id }),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
Ok(StatusCode::NO_CONTENT)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compatibilidad temporal: mantiene el endpoint anterior pero devuelve el servicio por defecto.
|
||||||
|
pub async fn get_organization_email_settings(
|
||||||
|
claims: Claims,
|
||||||
|
Org(org_ctx): Org,
|
||||||
|
State(pool): State<PgPool>,
|
||||||
|
) -> Result<Json<OrganizationEmailServiceResponse>, (StatusCode, String)> {
|
||||||
|
if claims.role != "admin" {
|
||||||
|
return Err((StatusCode::FORBIDDEN, "Se requiere acceso de administrador".into()));
|
||||||
|
}
|
||||||
|
|
||||||
|
ensure_bootstrap_service(&pool, org_ctx.id).await?;
|
||||||
|
|
||||||
|
let row = sqlx::query_as::<_, OrganizationEmailServiceRow>(
|
||||||
|
r#"
|
||||||
|
SELECT
|
||||||
|
id,
|
||||||
|
organization_id,
|
||||||
|
service_type,
|
||||||
|
provider_key,
|
||||||
|
display_name,
|
||||||
|
is_enabled,
|
||||||
|
is_default,
|
||||||
|
smtp_host,
|
||||||
|
smtp_port,
|
||||||
|
smtp_from,
|
||||||
|
smtp_username,
|
||||||
|
smtp_password,
|
||||||
|
smtp_starttls
|
||||||
|
FROM organization_email_services
|
||||||
|
WHERE organization_id = $1
|
||||||
|
ORDER BY is_default DESC, created_at ASC
|
||||||
|
LIMIT 1
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(org_ctx.id)
|
||||||
|
.fetch_one(&pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e| {
|
||||||
|
(
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
format!("Error al obtener servicio SMTP por defecto: {}", e),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
|
||||||
|
Ok(Json(to_response(row)))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compatibilidad temporal: actualiza el servicio por defecto actual.
|
||||||
|
pub async fn update_organization_email_settings(
|
||||||
|
claims: Claims,
|
||||||
|
Org(org_ctx): Org,
|
||||||
|
State(pool): State<PgPool>,
|
||||||
|
Json(payload): Json<UpsertOrganizationEmailServicePayload>,
|
||||||
|
) -> Result<Json<OrganizationEmailServiceResponse>, (StatusCode, String)> {
|
||||||
|
if claims.role != "admin" {
|
||||||
|
return Err((StatusCode::FORBIDDEN, "Se requiere acceso de administrador".into()));
|
||||||
|
}
|
||||||
|
|
||||||
|
ensure_bootstrap_service(&pool, org_ctx.id).await?;
|
||||||
|
|
||||||
|
let current_id: Uuid = sqlx::query_scalar(
|
||||||
|
"SELECT id FROM organization_email_services WHERE organization_id = $1 ORDER BY is_default DESC, created_at ASC LIMIT 1",
|
||||||
|
)
|
||||||
|
.bind(org_ctx.id)
|
||||||
|
.fetch_one(&pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e| {
|
||||||
|
(
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
format!("Error al resolver servicio SMTP actual: {}", e),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
|
||||||
|
update_organization_email_service(
|
||||||
|
claims,
|
||||||
|
Org(org_ctx),
|
||||||
|
State(pool),
|
||||||
|
Path(current_id),
|
||||||
|
Json(payload),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ pub mod exporter;
|
|||||||
mod external_handlers;
|
mod external_handlers;
|
||||||
mod handlers;
|
mod handlers;
|
||||||
mod handlers_branding;
|
mod handlers_branding;
|
||||||
|
mod handlers_email_settings;
|
||||||
mod handlers_exercise_settings;
|
mod handlers_exercise_settings;
|
||||||
mod handlers_assets;
|
mod handlers_assets;
|
||||||
mod handlers_dependencies;
|
mod handlers_dependencies;
|
||||||
@@ -162,16 +163,16 @@ async fn main() {
|
|||||||
.expose_headers([header::CONTENT_LENGTH, header::CONTENT_TYPE, header::CONTENT_RANGE, header::ACCEPT_RANGES]);
|
.expose_headers([header::CONTENT_LENGTH, header::CONTENT_TYPE, header::CONTENT_RANGE, header::ACCEPT_RANGES]);
|
||||||
|
|
||||||
use tower_governor::governor::GovernorConfigBuilder;
|
use tower_governor::governor::GovernorConfigBuilder;
|
||||||
|
use tower_governor::key_extractor::SmartIpKeyExtractor;
|
||||||
use tower_governor::GovernorLayer;
|
use tower_governor::GovernorLayer;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
let governor_conf = Arc::new(
|
let mut governor_conf = GovernorConfigBuilder::default()
|
||||||
GovernorConfigBuilder::default()
|
.const_per_second(5) // CMS usually has more complex operations, slightly lower limit
|
||||||
.per_second(5) // CMS usually has more complex operations, slightly lower limit
|
.const_burst_size(20)
|
||||||
.burst_size(20)
|
.key_extractor(SmartIpKeyExtractor);
|
||||||
.finish()
|
|
||||||
.unwrap(),
|
let governor_conf = Arc::new(governor_conf.finish().unwrap());
|
||||||
);
|
|
||||||
|
|
||||||
// Rutas protegidas que requieren autenticación y contexto de organización
|
// Rutas protegidas que requieren autenticación y contexto de organización
|
||||||
let protected_routes = Router::new()
|
let protected_routes = Router::new()
|
||||||
@@ -311,6 +312,25 @@ async fn main() {
|
|||||||
get(handlers_exercise_settings::get_organization_exercise_settings)
|
get(handlers_exercise_settings::get_organization_exercise_settings)
|
||||||
.put(handlers_exercise_settings::update_organization_exercise_settings),
|
.put(handlers_exercise_settings::update_organization_exercise_settings),
|
||||||
)
|
)
|
||||||
|
.route(
|
||||||
|
"/organization/email-settings",
|
||||||
|
get(handlers_email_settings::get_organization_email_settings)
|
||||||
|
.put(handlers_email_settings::update_organization_email_settings),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/organization/email-services",
|
||||||
|
get(handlers_email_settings::list_organization_email_services)
|
||||||
|
.post(handlers_email_settings::create_organization_email_service),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/organization/email-services/{id}",
|
||||||
|
put(handlers_email_settings::update_organization_email_service)
|
||||||
|
.delete(handlers_email_settings::delete_organization_email_service),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/organization/email-services/{id}/select",
|
||||||
|
post(handlers_email_settings::select_organization_email_service),
|
||||||
|
)
|
||||||
// Rutas de librerías de contenido
|
// Rutas de librerías de contenido
|
||||||
.route(
|
.route(
|
||||||
"/library/blocks",
|
"/library/blocks",
|
||||||
|
|||||||
@@ -30,3 +30,4 @@ mime_guess = "2.0"
|
|||||||
aws-config = "1"
|
aws-config = "1"
|
||||||
aws-sdk-s3 = "1"
|
aws-sdk-s3 = "1"
|
||||||
sha2.workspace = true
|
sha2.workspace = true
|
||||||
|
lettre = { version = "0.11", default-features = false, features = ["builder", "smtp-transport", "tokio1", "tokio1-native-tls"] }
|
||||||
|
|||||||
@@ -3,13 +3,250 @@ use axum::{
|
|||||||
extract::{Path, Query, State},
|
extract::{Path, Query, State},
|
||||||
http::StatusCode,
|
http::StatusCode,
|
||||||
};
|
};
|
||||||
|
use lettre::message::Mailbox;
|
||||||
|
use lettre::transport::smtp::authentication::Credentials;
|
||||||
|
use lettre::{AsyncSmtpTransport, AsyncTransport, Message, Tokio1Executor};
|
||||||
use common::auth::Claims;
|
use common::auth::Claims;
|
||||||
use common::middleware::Org;
|
use common::middleware::Org;
|
||||||
use common::models::{DiscussionPost, DiscussionThread, PostWithAuthor, ThreadWithAuthor};
|
use common::models::{DiscussionPost, DiscussionThread, PostWithAuthor, ThreadWithAuthor};
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
use sqlx::PgPool;
|
use sqlx::PgPool;
|
||||||
|
use std::env;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
struct ForumEmailRecipient {
|
||||||
|
user_id: Uuid,
|
||||||
|
email: String,
|
||||||
|
full_name: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, sqlx::FromRow)]
|
||||||
|
struct OrganizationEmailSettingsRow {
|
||||||
|
service_type: String,
|
||||||
|
smtp_enabled: bool,
|
||||||
|
smtp_host: Option<String>,
|
||||||
|
smtp_port: i32,
|
||||||
|
smtp_from: Option<String>,
|
||||||
|
smtp_username: Option<String>,
|
||||||
|
smtp_password: Option<String>,
|
||||||
|
smtp_starttls: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
struct SmtpConfig {
|
||||||
|
enabled: bool,
|
||||||
|
host: String,
|
||||||
|
from: String,
|
||||||
|
port: u16,
|
||||||
|
starttls: bool,
|
||||||
|
username: Option<String>,
|
||||||
|
password: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_bool(value: &str) -> bool {
|
||||||
|
let normalized = value.trim().to_lowercase();
|
||||||
|
normalized == "1" || normalized == "true" || normalized == "yes"
|
||||||
|
}
|
||||||
|
|
||||||
|
fn load_env_smtp_config() -> Result<SmtpConfig, String> {
|
||||||
|
let enabled = env::var("SMTP_ENABLED").map(|v| parse_bool(&v)).unwrap_or(false);
|
||||||
|
let host = env::var("SMTP_HOST").map_err(|_| "SMTP_HOST no está configurado".to_string())?;
|
||||||
|
let from = env::var("SMTP_FROM")
|
||||||
|
.ok()
|
||||||
|
.filter(|v| !v.trim().is_empty())
|
||||||
|
.unwrap_or_else(|| "OpenCCB <no-reply@openccb.local>".to_string());
|
||||||
|
let port = env::var("SMTP_PORT")
|
||||||
|
.ok()
|
||||||
|
.and_then(|v| v.parse::<u16>().ok())
|
||||||
|
.unwrap_or(587);
|
||||||
|
let starttls = env::var("SMTP_STARTTLS")
|
||||||
|
.map(|v| {
|
||||||
|
let normalized = v.trim().to_lowercase();
|
||||||
|
normalized == "1" || normalized == "true" || normalized == "yes"
|
||||||
|
})
|
||||||
|
.unwrap_or(false);
|
||||||
|
let username = env::var("SMTP_USERNAME").ok().filter(|v| !v.trim().is_empty());
|
||||||
|
let password = env::var("SMTP_PASSWORD").ok().filter(|v| !v.trim().is_empty());
|
||||||
|
|
||||||
|
Ok(SmtpConfig {
|
||||||
|
enabled,
|
||||||
|
host,
|
||||||
|
from,
|
||||||
|
port,
|
||||||
|
starttls,
|
||||||
|
username,
|
||||||
|
password,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn load_org_smtp_config(pool: &PgPool, organization_id: Uuid) -> Option<SmtpConfig> {
|
||||||
|
let row = sqlx::query_as::<_, OrganizationEmailSettingsRow>(
|
||||||
|
r#"
|
||||||
|
SELECT
|
||||||
|
service_type,
|
||||||
|
smtp_enabled,
|
||||||
|
smtp_host,
|
||||||
|
smtp_port,
|
||||||
|
smtp_from,
|
||||||
|
smtp_username,
|
||||||
|
smtp_password,
|
||||||
|
smtp_starttls
|
||||||
|
FROM organization_email_services
|
||||||
|
WHERE organization_id = $1
|
||||||
|
ORDER BY is_default DESC, created_at ASC
|
||||||
|
LIMIT 1
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(organization_id)
|
||||||
|
.fetch_optional(pool)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
let row = match row {
|
||||||
|
Ok(v) => v,
|
||||||
|
Err(e) => {
|
||||||
|
tracing::warn!(
|
||||||
|
"No se pudo cargar configuración SMTP por organización (fallback a entorno): {}",
|
||||||
|
e
|
||||||
|
);
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
}?;
|
||||||
|
|
||||||
|
if row.service_type.trim().to_lowercase() != "smtp" {
|
||||||
|
tracing::warn!(
|
||||||
|
"El servicio de email por defecto no es SMTP ({}), se usa fallback de entorno",
|
||||||
|
row.service_type
|
||||||
|
);
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
let host = row.smtp_host.unwrap_or_default().trim().to_string();
|
||||||
|
if host.is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
let from = row
|
||||||
|
.smtp_from
|
||||||
|
.unwrap_or_else(|| "OpenCCB <no-reply@openccb.local>".to_string())
|
||||||
|
.trim()
|
||||||
|
.to_string();
|
||||||
|
|
||||||
|
let port = u16::try_from(row.smtp_port).ok().filter(|v| *v > 0).unwrap_or(587);
|
||||||
|
|
||||||
|
Some(SmtpConfig {
|
||||||
|
enabled: row.smtp_enabled,
|
||||||
|
host,
|
||||||
|
from,
|
||||||
|
port,
|
||||||
|
starttls: row.smtp_starttls,
|
||||||
|
username: row.smtp_username.filter(|v| !v.trim().is_empty()),
|
||||||
|
password: row.smtp_password.filter(|v| !v.trim().is_empty()),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_smtp_mailer(config: &SmtpConfig) -> Result<AsyncSmtpTransport<Tokio1Executor>, String> {
|
||||||
|
let mut builder = if config.starttls {
|
||||||
|
AsyncSmtpTransport::<Tokio1Executor>::relay(&config.host)
|
||||||
|
.map_err(|e| format!("Error al crear relay SMTP con STARTTLS: {}", e))?
|
||||||
|
.port(config.port)
|
||||||
|
} else {
|
||||||
|
AsyncSmtpTransport::<Tokio1Executor>::builder_dangerous(&config.host).port(config.port)
|
||||||
|
};
|
||||||
|
|
||||||
|
if let (Some(user), Some(pass)) = (&config.username, &config.password) {
|
||||||
|
let user = user.trim().to_string();
|
||||||
|
let pass = pass.trim().to_string();
|
||||||
|
builder = builder.credentials(Credentials::new(user, pass));
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(builder.build())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn send_forum_email_notifications(
|
||||||
|
pool: &PgPool,
|
||||||
|
organization_id: Uuid,
|
||||||
|
recipients: &[ForumEmailRecipient],
|
||||||
|
subject: &str,
|
||||||
|
body: &str,
|
||||||
|
) {
|
||||||
|
if recipients.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let smtp_config = match load_org_smtp_config(pool, organization_id).await {
|
||||||
|
Some(config) => config,
|
||||||
|
None => match load_env_smtp_config() {
|
||||||
|
Ok(config) => config,
|
||||||
|
Err(e) => {
|
||||||
|
tracing::warn!("SMTP deshabilitado para foros: {}", e);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
if !smtp_config.enabled {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mailer = match build_smtp_mailer(&smtp_config) {
|
||||||
|
Ok(v) => v,
|
||||||
|
Err(e) => {
|
||||||
|
tracing::warn!("SMTP deshabilitado para foros: {}", e);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let from_mailbox: Mailbox = match smtp_config.from.parse() {
|
||||||
|
Ok(v) => v,
|
||||||
|
Err(e) => {
|
||||||
|
tracing::error!("SMTP_FROM inválido ({}): {}", smtp_config.from, e);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
for recipient in recipients {
|
||||||
|
let to_mailbox: Mailbox = match recipient.email.parse() {
|
||||||
|
Ok(v) => v,
|
||||||
|
Err(e) => {
|
||||||
|
tracing::warn!(
|
||||||
|
"Email inválido para notificación de foro ({}): {}",
|
||||||
|
recipient.email,
|
||||||
|
e
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
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)
|
||||||
|
{
|
||||||
|
Ok(msg) => msg,
|
||||||
|
Err(e) => {
|
||||||
|
tracing::warn!("No se pudo construir correo de foro: {}", e);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Err(e) = mailer.send(message).await {
|
||||||
|
tracing::warn!(
|
||||||
|
"Falló envío SMTP de foro para {}: {}",
|
||||||
|
recipient.email,
|
||||||
|
e
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ========== DTOs de Solicitud/Respuesta ==========
|
// ========== DTOs de Solicitud/Respuesta ==========
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
@@ -126,6 +363,13 @@ pub async fn create_thread(
|
|||||||
State(pool): State<PgPool>,
|
State(pool): State<PgPool>,
|
||||||
Json(payload): Json<CreateThreadPayload>,
|
Json(payload): Json<CreateThreadPayload>,
|
||||||
) -> Result<Json<DiscussionThread>, (StatusCode, String)> {
|
) -> Result<Json<DiscussionThread>, (StatusCode, String)> {
|
||||||
|
let author_name: Option<String> = sqlx::query_scalar("SELECT full_name FROM users WHERE id = $1")
|
||||||
|
.bind(claims.sub)
|
||||||
|
.fetch_optional(&pool)
|
||||||
|
.await
|
||||||
|
.ok()
|
||||||
|
.flatten();
|
||||||
|
|
||||||
let thread = sqlx::query_as::<_, DiscussionThread>(
|
let thread = sqlx::query_as::<_, DiscussionThread>(
|
||||||
"INSERT INTO discussion_threads (organization_id, course_id, lesson_id, author_id, title, content)
|
"INSERT INTO discussion_threads (organization_id, course_id, lesson_id, author_id, title, content)
|
||||||
VALUES ($1, $2, $3, $4, $5, $6)
|
VALUES ($1, $2, $3, $4, $5, $6)
|
||||||
@@ -135,8 +379,8 @@ pub async fn create_thread(
|
|||||||
.bind(course_id)
|
.bind(course_id)
|
||||||
.bind(payload.lesson_id)
|
.bind(payload.lesson_id)
|
||||||
.bind(claims.sub)
|
.bind(claims.sub)
|
||||||
.bind(payload.title)
|
.bind(&payload.title)
|
||||||
.bind(payload.content)
|
.bind(&payload.content)
|
||||||
.fetch_one(&pool)
|
.fetch_one(&pool)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||||
@@ -153,6 +397,61 @@ pub async fn create_thread(
|
|||||||
.execute(&pool)
|
.execute(&pool)
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
|
// Notificar a instructores/administradores del curso (excepto autor)
|
||||||
|
let instructor_recipients = sqlx::query_as::<_, (Uuid, String, Option<String>)>(
|
||||||
|
"SELECT DISTINCT u.id, u.email, u.full_name
|
||||||
|
FROM course_instructors ci
|
||||||
|
JOIN users u ON u.id = ci.user_id
|
||||||
|
WHERE ci.course_id = $1
|
||||||
|
AND ci.organization_id = $2
|
||||||
|
AND u.id != $3
|
||||||
|
AND u.email IS NOT NULL
|
||||||
|
AND trim(u.email) != ''",
|
||||||
|
)
|
||||||
|
.bind(course_id)
|
||||||
|
.bind(org_ctx.id)
|
||||||
|
.bind(claims.sub)
|
||||||
|
.fetch_all(&pool)
|
||||||
|
.await
|
||||||
|
.unwrap_or_default()
|
||||||
|
.into_iter()
|
||||||
|
.map(|(user_id, email, full_name)| ForumEmailRecipient {
|
||||||
|
user_id,
|
||||||
|
email,
|
||||||
|
full_name,
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
|
||||||
|
for recipient in &instructor_recipients {
|
||||||
|
let _ = sqlx::query(
|
||||||
|
"INSERT INTO notifications (organization_id, user_id, title, message, notification_type, link_url)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6)",
|
||||||
|
)
|
||||||
|
.bind(org_ctx.id)
|
||||||
|
.bind(recipient.user_id)
|
||||||
|
.bind(format!("Nuevo hilo en foro: {}", thread.title))
|
||||||
|
.bind(if thread.content.len() > 140 {
|
||||||
|
format!("{}...", &thread.content[..140])
|
||||||
|
} else {
|
||||||
|
thread.content.clone()
|
||||||
|
})
|
||||||
|
.bind("forum_thread")
|
||||||
|
.bind(format!("/courses/{}#discussions", course_id))
|
||||||
|
.execute(&pool)
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
|
||||||
Ok(Json(thread))
|
Ok(Json(thread))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -325,8 +624,11 @@ pub async fn create_post(
|
|||||||
) -> Result<Json<DiscussionPost>, (StatusCode, String)> {
|
) -> Result<Json<DiscussionPost>, (StatusCode, String)> {
|
||||||
// Verificar si el hilo está bloqueado
|
// Verificar si el hilo está bloqueado
|
||||||
let thread =
|
let thread =
|
||||||
sqlx::query_as::<_, (bool,)>("SELECT is_locked FROM discussion_threads WHERE id = $1")
|
sqlx::query_as::<_, (bool, String, Uuid, Uuid)>(
|
||||||
|
"SELECT is_locked, title, course_id, author_id FROM discussion_threads WHERE id = $1 AND organization_id = $2",
|
||||||
|
)
|
||||||
.bind(thread_id)
|
.bind(thread_id)
|
||||||
|
.bind(org_ctx.id)
|
||||||
.fetch_one(&pool)
|
.fetch_one(&pool)
|
||||||
.await
|
.await
|
||||||
.map_err(|_| (StatusCode::NOT_FOUND, "Hilo no encontrado".to_string()))?;
|
.map_err(|_| (StatusCode::NOT_FOUND, "Hilo no encontrado".to_string()))?;
|
||||||
@@ -335,6 +637,13 @@ pub async fn create_post(
|
|||||||
return Err((StatusCode::FORBIDDEN, "El hilo está bloqueado".to_string()));
|
return Err((StatusCode::FORBIDDEN, "El hilo está bloqueado".to_string()));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let author_name: Option<String> = sqlx::query_scalar("SELECT full_name FROM users WHERE id = $1")
|
||||||
|
.bind(claims.sub)
|
||||||
|
.fetch_optional(&pool)
|
||||||
|
.await
|
||||||
|
.ok()
|
||||||
|
.flatten();
|
||||||
|
|
||||||
let post = sqlx::query_as::<_, DiscussionPost>(
|
let post = sqlx::query_as::<_, DiscussionPost>(
|
||||||
"INSERT INTO discussion_posts (organization_id, thread_id, parent_post_id, author_id, content)
|
"INSERT INTO discussion_posts (organization_id, thread_id, parent_post_id, author_id, content)
|
||||||
VALUES ($1, $2, $3, $4, $5)
|
VALUES ($1, $2, $3, $4, $5)
|
||||||
@@ -349,7 +658,80 @@ pub async fn create_post(
|
|||||||
.await
|
.await
|
||||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||||
|
|
||||||
// TODO: Enviar notificaciones a los usuarios suscritos
|
// Notificar a los suscritos al hilo (excepto autor de la respuesta)
|
||||||
|
let mut recipients = sqlx::query_as::<_, (Uuid, String, Option<String>)>(
|
||||||
|
"SELECT DISTINCT u.id, u.email, u.full_name
|
||||||
|
FROM discussion_subscriptions ds
|
||||||
|
JOIN users u ON u.id = ds.user_id
|
||||||
|
WHERE ds.thread_id = $1
|
||||||
|
AND ds.organization_id = $2
|
||||||
|
AND ds.user_id != $3
|
||||||
|
AND u.email IS NOT NULL
|
||||||
|
AND trim(u.email) != ''",
|
||||||
|
)
|
||||||
|
.bind(thread_id)
|
||||||
|
.bind(org_ctx.id)
|
||||||
|
.bind(claims.sub)
|
||||||
|
.fetch_all(&pool)
|
||||||
|
.await
|
||||||
|
.unwrap_or_default()
|
||||||
|
.into_iter()
|
||||||
|
.map(|(user_id, email, full_name)| ForumEmailRecipient {
|
||||||
|
user_id,
|
||||||
|
email,
|
||||||
|
full_name,
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
|
||||||
|
// Si el autor del hilo no está suscrito, añadirlo igualmente (si no es quien respondió)
|
||||||
|
if thread.3 != claims.sub && !recipients.iter().any(|r| r.user_id == thread.3) {
|
||||||
|
if let Ok(Some((email, full_name))) = sqlx::query_as::<_, (String, Option<String>)>(
|
||||||
|
"SELECT email, full_name FROM users WHERE id = $1 AND organization_id = $2",
|
||||||
|
)
|
||||||
|
.bind(thread.3)
|
||||||
|
.bind(org_ctx.id)
|
||||||
|
.fetch_optional(&pool)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
if !email.trim().is_empty() {
|
||||||
|
recipients.push(ForumEmailRecipient {
|
||||||
|
user_id: thread.3,
|
||||||
|
email,
|
||||||
|
full_name,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for recipient in &recipients {
|
||||||
|
let _ = sqlx::query(
|
||||||
|
"INSERT INTO notifications (organization_id, user_id, title, message, notification_type, link_url)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6)",
|
||||||
|
)
|
||||||
|
.bind(org_ctx.id)
|
||||||
|
.bind(recipient.user_id)
|
||||||
|
.bind(format!("Nueva respuesta en: {}", thread.1))
|
||||||
|
.bind(if post.content.len() > 140 {
|
||||||
|
format!("{}...", &post.content[..140])
|
||||||
|
} else {
|
||||||
|
post.content.clone()
|
||||||
|
})
|
||||||
|
.bind("forum_reply")
|
||||||
|
.bind(format!("/courses/{}#discussions", thread.2))
|
||||||
|
.execute(&pool)
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
|
||||||
Ok(Json(post))
|
Ok(Json(post))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -120,16 +120,16 @@ async fn main() {
|
|||||||
.expose_headers([header::CONTENT_LENGTH, header::CONTENT_TYPE]);
|
.expose_headers([header::CONTENT_LENGTH, header::CONTENT_TYPE]);
|
||||||
|
|
||||||
use tower_governor::governor::GovernorConfigBuilder;
|
use tower_governor::governor::GovernorConfigBuilder;
|
||||||
|
use tower_governor::key_extractor::SmartIpKeyExtractor;
|
||||||
use tower_governor::GovernorLayer;
|
use tower_governor::GovernorLayer;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
let governor_conf = Arc::new(
|
let mut governor_conf = GovernorConfigBuilder::default()
|
||||||
GovernorConfigBuilder::default()
|
.const_per_second(10)
|
||||||
.per_second(10)
|
.const_burst_size(50)
|
||||||
.burst_size(50)
|
.key_extractor(SmartIpKeyExtractor);
|
||||||
.finish()
|
|
||||||
.unwrap(),
|
let governor_conf = Arc::new(governor_conf.finish().unwrap());
|
||||||
);
|
|
||||||
|
|
||||||
// Rate limiter solo para rutas protegidas (después del middleware de autenticación)
|
// Rate limiter solo para rutas protegidas (después del middleware de autenticación)
|
||||||
let protected_routes = Router::new()
|
let protected_routes = Router::new()
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import BrandingSettings from "@/components/BrandingSettings";
|
import BrandingSettings from "@/components/BrandingSettings";
|
||||||
|
import EmailSettings from "@/components/EmailSettings";
|
||||||
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";
|
||||||
@@ -28,6 +29,7 @@ export default function SettingsPage() {
|
|||||||
>
|
>
|
||||||
<div className="space-y-8">
|
<div className="space-y-8">
|
||||||
<BrandingSettings />
|
<BrandingSettings />
|
||||||
|
<EmailSettings />
|
||||||
<ExerciseFeatureSettings />
|
<ExerciseFeatureSettings />
|
||||||
</div>
|
</div>
|
||||||
</PageLayout>
|
</PageLayout>
|
||||||
|
|||||||
@@ -0,0 +1,374 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
import {
|
||||||
|
cmsApi,
|
||||||
|
OrganizationEmailService,
|
||||||
|
UpsertOrganizationEmailServicePayload,
|
||||||
|
} from "@/lib/api";
|
||||||
|
|
||||||
|
type EditableService = {
|
||||||
|
id?: string;
|
||||||
|
display_name: string;
|
||||||
|
provider_key: string;
|
||||||
|
smtp_enabled: boolean;
|
||||||
|
is_default: boolean;
|
||||||
|
smtp_host: string;
|
||||||
|
smtp_port: number;
|
||||||
|
smtp_from: string;
|
||||||
|
smtp_username: string;
|
||||||
|
smtp_starttls: boolean;
|
||||||
|
has_password: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
function toEditable(service: OrganizationEmailService): EditableService {
|
||||||
|
return {
|
||||||
|
id: service.id,
|
||||||
|
display_name: service.display_name,
|
||||||
|
provider_key: service.provider_key,
|
||||||
|
smtp_enabled: service.smtp_enabled,
|
||||||
|
is_default: service.is_default,
|
||||||
|
smtp_host: service.smtp_host || "",
|
||||||
|
smtp_port: service.smtp_port || 587,
|
||||||
|
smtp_from: service.smtp_from || "",
|
||||||
|
smtp_username: service.smtp_username || "",
|
||||||
|
smtp_starttls: service.smtp_starttls,
|
||||||
|
has_password: service.has_password,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function newServiceTemplate(): EditableService {
|
||||||
|
return {
|
||||||
|
display_name: "Nuevo servicio SMTP",
|
||||||
|
provider_key: "custom",
|
||||||
|
smtp_enabled: true,
|
||||||
|
is_default: false,
|
||||||
|
smtp_host: "",
|
||||||
|
smtp_port: 587,
|
||||||
|
smtp_from: "",
|
||||||
|
smtp_username: "",
|
||||||
|
smtp_starttls: true,
|
||||||
|
has_password: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function EmailSettings() {
|
||||||
|
const [services, setServices] = useState<OrganizationEmailService[]>([]);
|
||||||
|
const [selectedId, setSelectedId] = useState<string>("");
|
||||||
|
const [form, setForm] = useState<EditableService>(newServiceTemplate());
|
||||||
|
const [smtpPassword, setSmtpPassword] = useState("");
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
|
||||||
|
const selectedService = useMemo(
|
||||||
|
() => services.find((svc) => svc.id === selectedId),
|
||||||
|
[services, selectedId],
|
||||||
|
);
|
||||||
|
|
||||||
|
const loadServices = async () => {
|
||||||
|
const data = await cmsApi.listOrganizationEmailServices();
|
||||||
|
setServices(data);
|
||||||
|
const defaultService = data.find((svc) => svc.is_default) || data[0];
|
||||||
|
if (defaultService) {
|
||||||
|
setSelectedId(defaultService.id);
|
||||||
|
setForm(toEditable(defaultService));
|
||||||
|
} else {
|
||||||
|
setSelectedId("");
|
||||||
|
setForm(newServiceTemplate());
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const run = async () => {
|
||||||
|
try {
|
||||||
|
await loadServices();
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to load email services:", error);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
run();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const setField = <K extends keyof EditableService>(key: K, value: EditableService[K]) => {
|
||||||
|
setForm((prev) => ({ ...prev, [key]: value }));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSelect = async (id: string) => {
|
||||||
|
try {
|
||||||
|
await cmsApi.selectOrganizationEmailService(id);
|
||||||
|
await loadServices();
|
||||||
|
alert("Servicio por defecto actualizado.");
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to select email service:", error);
|
||||||
|
alert("No se pudo seleccionar el servicio por defecto.");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = async (id: string) => {
|
||||||
|
if (!confirm("¿Eliminar este servicio SMTP?")) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await cmsApi.deleteOrganizationEmailService(id);
|
||||||
|
await loadServices();
|
||||||
|
setSmtpPassword("");
|
||||||
|
alert("Servicio eliminado.");
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to delete email service:", error);
|
||||||
|
alert("No se pudo eliminar el servicio.");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleNew = () => {
|
||||||
|
setSelectedId("");
|
||||||
|
setForm(newServiceTemplate());
|
||||||
|
setSmtpPassword("");
|
||||||
|
};
|
||||||
|
|
||||||
|
const toPayload = (): UpsertOrganizationEmailServicePayload => ({
|
||||||
|
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_default: form.is_default,
|
||||||
|
smtp_host: form.smtp_host.trim() || undefined,
|
||||||
|
smtp_port: Number(form.smtp_port) || 587,
|
||||||
|
smtp_from: form.smtp_from.trim() || undefined,
|
||||||
|
smtp_username: form.smtp_username.trim() || undefined,
|
||||||
|
smtp_starttls: form.smtp_starttls,
|
||||||
|
...(smtpPassword.trim() ? { smtp_password: smtpPassword.trim() } : {}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
const payload = toPayload();
|
||||||
|
if (form.id) {
|
||||||
|
await cmsApi.updateOrganizationEmailService(form.id, payload);
|
||||||
|
} else {
|
||||||
|
await cmsApi.createOrganizationEmailService(payload);
|
||||||
|
}
|
||||||
|
await loadServices();
|
||||||
|
setSmtpPassword("");
|
||||||
|
alert("Servicio SMTP guardado correctamente.");
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to save email service:", error);
|
||||||
|
alert("No se pudo guardar el servicio SMTP.");
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return <div className="p-8 text-center text-gray-400 animate-pulse">Cargando servicios 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> Servicios de Correo por Empresa
|
||||||
|
</legend>
|
||||||
|
|
||||||
|
<p className="text-sm text-slate-600 dark:text-gray-400 mt-4">
|
||||||
|
Cada empresa puede registrar varios servicios de correo (SMTP) y seleccionar cuál usar como predeterminado.
|
||||||
|
Si no hay registros, se crea uno automáticamente con valores de entorno.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="mt-6 grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
{services.map((svc) => (
|
||||||
|
<div
|
||||||
|
key={svc.id}
|
||||||
|
className={`rounded-xl border p-4 ${svc.is_default ? "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"}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between gap-3">
|
||||||
|
<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"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{svc.is_default && (
|
||||||
|
<span className="text-[10px] font-bold uppercase tracking-wider rounded-full px-2 py-1 bg-emerald-600 text-white">
|
||||||
|
Predeterminado
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-3 flex flex-wrap gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="px-3 py-1.5 text-xs rounded-lg bg-blue-600 text-white hover:bg-blue-700"
|
||||||
|
onClick={() => {
|
||||||
|
setSelectedId(svc.id);
|
||||||
|
setForm(toEditable(svc));
|
||||||
|
setSmtpPassword("");
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Editar
|
||||||
|
</button>
|
||||||
|
{!svc.is_default && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="px-3 py-1.5 text-xs rounded-lg bg-emerald-600 text-white hover:bg-emerald-700"
|
||||||
|
onClick={() => handleSelect(svc.id)}
|
||||||
|
>
|
||||||
|
Usar este
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{!svc.is_default && services.length > 1 && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="px-3 py-1.5 text-xs rounded-lg bg-rose-600 text-white hover:bg-rose-700"
|
||||||
|
onClick={() => handleDelete(svc.id)}
|
||||||
|
>
|
||||||
|
Eliminar
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-5 flex justify-end">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleNew}
|
||||||
|
className="px-4 py-2 rounded-lg bg-slate-800 text-white hover:bg-slate-900 dark:bg-slate-700 dark:hover:bg-slate-600"
|
||||||
|
>
|
||||||
|
Nuevo servicio
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-6 rounded-xl border border-slate-200 dark:border-white/10 p-4 bg-slate-50 dark:bg-black/20">
|
||||||
|
<h4 className="text-sm font-bold text-slate-900 dark:text-white">
|
||||||
|
{form.id ? "Editar servicio" : "Crear servicio"}
|
||||||
|
</h4>
|
||||||
|
|
||||||
|
<div className="mt-4 grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<label className="block md:col-span-2">
|
||||||
|
<span className="block text-xs font-semibold uppercase tracking-wider text-slate-500 dark:text-gray-400 mb-1">Nombre del servicio</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>
|
||||||
|
|
||||||
|
<label className="block">
|
||||||
|
<span className="block text-xs font-semibold uppercase tracking-wider text-slate-500 dark:text-gray-400 mb-1">Provider</span>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={form.provider_key}
|
||||||
|
onChange={(e) => setField("provider_key", e.target.value)}
|
||||||
|
placeholder="gmail, sendgrid, ses, mailpit..."
|
||||||
|
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="flex items-center gap-3 mt-6">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={form.smtp_enabled}
|
||||||
|
onChange={(e) => setField("smtp_enabled", e.target.checked)}
|
||||||
|
/>
|
||||||
|
<span className="text-sm text-slate-800 dark:text-gray-200">Habilitado</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className="block">
|
||||||
|
<span className="block text-xs font-semibold uppercase tracking-wider text-slate-500 dark:text-gray-400 mb-1">Servidor SMTP</span>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={form.smtp_host}
|
||||||
|
onChange={(e) => setField("smtp_host", e.target.value)}
|
||||||
|
placeholder="smtp.gmail.com"
|
||||||
|
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">Puerto</span>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
max={65535}
|
||||||
|
value={form.smtp_port}
|
||||||
|
onChange={(e) => setField("smtp_port", Number(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>
|
||||||
|
|
||||||
|
<label className="block md:col-span-2">
|
||||||
|
<span className="block text-xs font-semibold uppercase tracking-wider text-slate-500 dark:text-gray-400 mb-1">Remitente</span>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={form.smtp_from}
|
||||||
|
onChange={(e) => setField("smtp_from", e.target.value)}
|
||||||
|
placeholder="OpenCCB <no-reply@dominio.com>"
|
||||||
|
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">Usuario SMTP</span>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={form.smtp_username}
|
||||||
|
onChange={(e) => setField("smtp_username", 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>
|
||||||
|
|
||||||
|
<label className="block">
|
||||||
|
<span className="block text-xs font-semibold uppercase tracking-wider text-slate-500 dark:text-gray-400 mb-1">
|
||||||
|
Contraseña SMTP {form.has_password ? "(guardada)" : ""}
|
||||||
|
</span>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={smtpPassword}
|
||||||
|
onChange={(e) => setSmtpPassword(e.target.value)}
|
||||||
|
placeholder={form.has_password ? "•••••••• (dejar vacío para mantener)" : "Nueva contraseña"}
|
||||||
|
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="flex items-center gap-3">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={form.smtp_starttls}
|
||||||
|
onChange={(e) => setField("smtp_starttls", e.target.checked)}
|
||||||
|
/>
|
||||||
|
<span className="text-sm text-slate-800 dark:text-gray-200">Usar STARTTLS</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className="flex items-center gap-3">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={form.is_default}
|
||||||
|
onChange={(e) => setField("is_default", e.target.checked)}
|
||||||
|
/>
|
||||||
|
<span className="text-sm text-slate-800 dark:text-gray-200">Marcar como predeterminado</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-6 flex justify-end">
|
||||||
|
<button
|
||||||
|
onClick={handleSave}
|
||||||
|
disabled={saving}
|
||||||
|
className="px-6 py-3 rounded-xl bg-blue-600 text-white font-bold hover:bg-blue-700 transition-all disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{saving ? "Guardando..." : form.id ? "Guardar cambios" : "Crear servicio"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{selectedService && (
|
||||||
|
<p className="mt-4 text-xs text-slate-500 dark:text-gray-400">
|
||||||
|
Editando: <strong>{selectedService.display_name}</strong>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</fieldset>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -302,6 +302,36 @@ export interface OrganizationSSOConfig {
|
|||||||
updated_at: string;
|
updated_at: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface OrganizationEmailService {
|
||||||
|
id: string;
|
||||||
|
organization_id: string;
|
||||||
|
service_type: string;
|
||||||
|
provider_key: string;
|
||||||
|
display_name: string;
|
||||||
|
smtp_enabled: boolean;
|
||||||
|
is_default: boolean;
|
||||||
|
smtp_host?: string;
|
||||||
|
smtp_port: number;
|
||||||
|
smtp_from?: string;
|
||||||
|
smtp_username?: string;
|
||||||
|
smtp_starttls: boolean;
|
||||||
|
has_password: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpsertOrganizationEmailServicePayload {
|
||||||
|
service_type: string;
|
||||||
|
provider_key: string;
|
||||||
|
display_name: string;
|
||||||
|
smtp_enabled: boolean;
|
||||||
|
is_default: boolean;
|
||||||
|
smtp_host?: string;
|
||||||
|
smtp_port: number;
|
||||||
|
smtp_from?: string;
|
||||||
|
smtp_username?: string;
|
||||||
|
smtp_password?: string;
|
||||||
|
smtp_starttls: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
export interface ProvisionPayload {
|
export interface ProvisionPayload {
|
||||||
org_name: string;
|
org_name: string;
|
||||||
org_domain?: string;
|
org_domain?: string;
|
||||||
@@ -881,6 +911,18 @@ export const cmsApi = {
|
|||||||
},
|
},
|
||||||
getSSOConfig: (): Promise<OrganizationSSOConfig> => apiFetch('/organization/sso'),
|
getSSOConfig: (): Promise<OrganizationSSOConfig> => apiFetch('/organization/sso'),
|
||||||
updateSSOConfig: (payload: Partial<OrganizationSSOConfig>): Promise<void> => apiFetch('/organization/sso', { method: 'PUT', body: JSON.stringify(payload) }),
|
updateSSOConfig: (payload: Partial<OrganizationSSOConfig>): Promise<void> => apiFetch('/organization/sso', { method: 'PUT', body: JSON.stringify(payload) }),
|
||||||
|
getOrganizationEmailSettings: (): Promise<OrganizationEmailService> => apiFetch('/organization/email-settings'),
|
||||||
|
updateOrganizationEmailSettings: (payload: UpsertOrganizationEmailServicePayload): Promise<OrganizationEmailService> =>
|
||||||
|
apiFetch('/organization/email-settings', { method: 'PUT', body: JSON.stringify(payload) }),
|
||||||
|
listOrganizationEmailServices: (): Promise<OrganizationEmailService[]> => apiFetch('/organization/email-services'),
|
||||||
|
createOrganizationEmailService: (payload: UpsertOrganizationEmailServicePayload): Promise<OrganizationEmailService> =>
|
||||||
|
apiFetch('/organization/email-services', { method: 'POST', body: JSON.stringify(payload) }),
|
||||||
|
updateOrganizationEmailService: (id: string, payload: UpsertOrganizationEmailServicePayload): Promise<OrganizationEmailService> =>
|
||||||
|
apiFetch(`/organization/email-services/${id}`, { method: 'PUT', body: JSON.stringify(payload) }),
|
||||||
|
deleteOrganizationEmailService: (id: string): Promise<void> =>
|
||||||
|
apiFetch(`/organization/email-services/${id}`, { method: 'DELETE' }),
|
||||||
|
selectOrganizationEmailService: (id: string): Promise<void> =>
|
||||||
|
apiFetch(`/organization/email-services/${id}/select`, { method: 'POST' }),
|
||||||
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) }),
|
||||||
|
|||||||
Reference in New Issue
Block a user