feat: add organization exercise settings management

- Created a new SQL migration to define the organization_exercise_settings table with relevant fields and an index.
- Implemented handlers for loading and updating organization exercise settings in Rust, including default values and upsert functionality.
- Developed a React component for managing exercise feature settings, allowing toggling of features and saving updates to the backend.
This commit is contained in:
2026-04-13 16:55:09 -04:00
parent 7f3e1ce9b1
commit c750ad0423
17 changed files with 899 additions and 44 deletions
@@ -0,0 +1,197 @@
use axum::{
Json,
extract::State,
http::StatusCode,
};
use common::auth::Claims;
use serde::{Deserialize, Serialize};
use serde_json::json;
use sqlx::PgPool;
use uuid::Uuid;
use super::handlers::{log_action, Org};
#[derive(Debug, Serialize, Deserialize, Clone, sqlx::FromRow)]
pub struct OrganizationExerciseSettings {
pub organization_id: Uuid,
pub audio_response_enabled: bool,
pub hotspot_enabled: bool,
pub memory_match_enabled: bool,
pub peer_review_enabled: bool,
pub role_playing_enabled: bool,
pub mermaid_enabled: bool,
pub code_lab_enabled: bool,
}
impl OrganizationExerciseSettings {
pub fn defaults(organization_id: Uuid) -> Self {
Self {
organization_id,
audio_response_enabled: true,
hotspot_enabled: true,
memory_match_enabled: true,
peer_review_enabled: true,
role_playing_enabled: true,
mermaid_enabled: false,
code_lab_enabled: true,
}
}
pub fn is_enabled(&self, feature: &str) -> bool {
match feature {
"audio-response" => self.audio_response_enabled,
"hotspot" => self.hotspot_enabled,
"memory-match" => self.memory_match_enabled,
"peer-review" => self.peer_review_enabled,
"role-playing" => self.role_playing_enabled,
"mermaid" => self.mermaid_enabled,
"code-lab" => self.code_lab_enabled,
_ => true,
}
}
}
#[derive(Debug, Serialize, Deserialize)]
pub struct UpdateOrganizationExerciseSettingsPayload {
pub audio_response_enabled: bool,
pub hotspot_enabled: bool,
pub memory_match_enabled: bool,
pub peer_review_enabled: bool,
pub role_playing_enabled: bool,
pub mermaid_enabled: bool,
pub code_lab_enabled: bool,
}
pub async fn load_organization_exercise_settings(
pool: &PgPool,
organization_id: Uuid,
) -> Result<OrganizationExerciseSettings, sqlx::Error> {
let settings = sqlx::query_as::<_, OrganizationExerciseSettings>(
r#"
SELECT
organization_id,
audio_response_enabled,
hotspot_enabled,
memory_match_enabled,
peer_review_enabled,
role_playing_enabled,
mermaid_enabled,
code_lab_enabled
FROM organization_exercise_settings
WHERE organization_id = $1
"#,
)
.bind(organization_id)
.fetch_optional(pool)
.await?;
Ok(settings.unwrap_or_else(|| OrganizationExerciseSettings::defaults(organization_id)))
}
async fn upsert_organization_exercise_settings(
pool: &PgPool,
organization_id: Uuid,
payload: &UpdateOrganizationExerciseSettingsPayload,
) -> Result<OrganizationExerciseSettings, sqlx::Error> {
sqlx::query_as::<_, OrganizationExerciseSettings>(
r#"
INSERT INTO organization_exercise_settings (
organization_id,
audio_response_enabled,
hotspot_enabled,
memory_match_enabled,
peer_review_enabled,
role_playing_enabled,
mermaid_enabled,
code_lab_enabled,
updated_at
)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, NOW())
ON CONFLICT (organization_id) DO UPDATE SET
audio_response_enabled = EXCLUDED.audio_response_enabled,
hotspot_enabled = EXCLUDED.hotspot_enabled,
memory_match_enabled = EXCLUDED.memory_match_enabled,
peer_review_enabled = EXCLUDED.peer_review_enabled,
role_playing_enabled = EXCLUDED.role_playing_enabled,
mermaid_enabled = EXCLUDED.mermaid_enabled,
code_lab_enabled = EXCLUDED.code_lab_enabled,
updated_at = NOW()
RETURNING
organization_id,
audio_response_enabled,
hotspot_enabled,
memory_match_enabled,
peer_review_enabled,
role_playing_enabled,
mermaid_enabled,
code_lab_enabled
"#,
)
.bind(organization_id)
.bind(payload.audio_response_enabled)
.bind(payload.hotspot_enabled)
.bind(payload.memory_match_enabled)
.bind(payload.peer_review_enabled)
.bind(payload.role_playing_enabled)
.bind(payload.mermaid_enabled)
.bind(payload.code_lab_enabled)
.fetch_one(pool)
.await
}
pub async fn get_organization_exercise_settings(
Org(org_ctx): Org,
State(pool): State<PgPool>,
) -> Result<Json<OrganizationExerciseSettings>, (StatusCode, String)> {
let settings = load_organization_exercise_settings(&pool, org_ctx.id)
.await
.map_err(|e| {
(
StatusCode::INTERNAL_SERVER_ERROR,
format!("Error al cargar configuración de ejercicios: {}", e),
)
})?;
Ok(Json(settings))
}
pub async fn update_organization_exercise_settings(
claims: Claims,
Org(org_ctx): Org,
State(pool): State<PgPool>,
Json(payload): Json<UpdateOrganizationExerciseSettingsPayload>,
) -> Result<Json<OrganizationExerciseSettings>, (StatusCode, String)> {
if claims.role != "admin" {
return Err((StatusCode::FORBIDDEN, "Se requiere acceso de administrador".into()));
}
let settings = upsert_organization_exercise_settings(&pool, org_ctx.id, &payload)
.await
.map_err(|e| {
(
StatusCode::INTERNAL_SERVER_ERROR,
format!("Error al guardar configuración de ejercicios: {}", e),
)
})?;
log_action(
&pool,
claims.org,
claims.sub,
"UPDATE_EXERCISE_SETTINGS",
"Organization",
org_ctx.id,
json!({
"audio_response_enabled": settings.audio_response_enabled,
"hotspot_enabled": settings.hotspot_enabled,
"memory_match_enabled": settings.memory_match_enabled,
"peer_review_enabled": settings.peer_review_enabled,
"role_playing_enabled": settings.role_playing_enabled,
"mermaid_enabled": settings.mermaid_enabled,
"code_lab_enabled": settings.code_lab_enabled,
}),
)
.await;
Ok(Json(settings))
}