feat: Add i18n support, new content block types, course export, and lesson interaction tracking.

This commit is contained in:
2026-01-17 02:19:39 -03:00
parent b166387a48
commit 05faa20993
50 changed files with 3368 additions and 388 deletions
@@ -0,0 +1,16 @@
-- Create lesson_interactions table for engagement tracking
CREATE TABLE IF NOT EXISTS lesson_interactions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
organization_id UUID NOT NULL,
user_id UUID NOT NULL,
lesson_id UUID NOT NULL,
video_timestamp FLOAT, -- Timestamp in seconds for video content
event_type VARCHAR(50) NOT NULL, -- 'heartbeat', 'pause', 'seek', 'complete', 'start'
metadata JSONB, -- Additional data (segment duration, playback speed, etc.)
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
-- Index for efficient querying of heatmaps
CREATE INDEX IF NOT EXISTS idx_lesson_interactions_lesson_id ON lesson_interactions(lesson_id);
CREATE INDEX IF NOT EXISTS idx_lesson_interactions_user_id ON lesson_interactions(user_id);
CREATE INDEX IF NOT EXISTS idx_lesson_interactions_org_id ON lesson_interactions(organization_id);
@@ -0,0 +1,16 @@
-- Create notifications table for in-app alerts
CREATE TABLE IF NOT EXISTS notifications (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
organization_id UUID NOT NULL,
user_id UUID NOT NULL,
title VARCHAR(255) NOT NULL,
message TEXT NOT NULL,
notification_type VARCHAR(50) DEFAULT 'info', -- 'info', 'warning', 'success', 'deadline'
is_read BOOLEAN DEFAULT FALSE,
link_url VARCHAR(255), -- Optional link to redirect the user
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_notifications_user_id ON notifications(user_id);
CREATE INDEX IF NOT EXISTS idx_notifications_org_id ON notifications(organization_id);
CREATE INDEX IF NOT EXISTS idx_notifications_unread ON notifications(user_id) WHERE is_read = FALSE;
+136 -6
View File
@@ -7,8 +7,8 @@ use bcrypt::{DEFAULT_COST, hash, verify};
use common::auth::{Claims, create_jwt};
use common::middleware::Org;
use common::models::{
AuthResponse, Course, CourseAnalytics, Enrollment, Lesson, LessonAnalytics, Module,
Organization, User, UserResponse,
AuthResponse, Course, CourseAnalytics, Enrollment, HeatmapPoint, Lesson, LessonAnalytics,
Module, Notification, Organization, User, UserResponse,
};
use serde::Deserialize;
use sqlx::{PgPool, Row};
@@ -104,6 +104,13 @@ pub struct GradeSubmissionPayload {
pub metadata: Option<serde_json::Value>,
}
#[derive(Deserialize)]
pub struct InteractionPayload {
pub video_timestamp: Option<f64>,
pub event_type: String, // 'heartbeat', 'pause', 'seek', 'complete', 'start'
pub metadata: Option<serde_json::Value>,
}
pub async fn register(
State(pool): State<PgPool>,
Json(payload): Json<AuthPayload>,
@@ -442,7 +449,7 @@ pub async fn ingest_course(
}
pub async fn get_course_outline(
Org(org_ctx): Org,
Org(_org_ctx): Org,
State(pool): State<PgPool>,
Path(id): Path<Uuid>,
) -> Result<Json<common::models::PublishedCourse>, StatusCode> {
@@ -503,7 +510,7 @@ pub async fn get_course_outline(
}
pub async fn get_lesson_content(
Org(org_ctx): Org,
Org(_org_ctx): Org,
State(pool): State<PgPool>,
Path(id): Path<Uuid>,
) -> Result<Json<Lesson>, StatusCode> {
@@ -518,7 +525,7 @@ pub async fn get_lesson_content(
}
pub async fn get_user_enrollments(
Org(org_ctx): Org,
Org(_org_ctx): Org,
State(pool): State<PgPool>,
Path(user_id): Path<Uuid>,
) -> Result<Json<Vec<Enrollment>>, StatusCode> {
@@ -748,7 +755,7 @@ pub async fn get_leaderboard(
}
pub async fn get_user_course_grades(
Org(org_ctx): Org,
Org(_org_ctx): Org,
State(pool): State<PgPool>,
Path((user_id, course_id)): Path<(Uuid, Uuid)>,
) -> Result<Json<Vec<common::models::UserGrade>>, StatusCode> {
@@ -865,6 +872,129 @@ pub async fn get_advanced_analytics(
}))
}
pub async fn record_interaction(
Org(org_ctx): Org,
Path(lesson_id): Path<Uuid>,
claims: Claims,
State(pool): State<PgPool>,
Json(payload): Json<InteractionPayload>,
) -> Result<StatusCode, StatusCode> {
sqlx::query(
"INSERT INTO lesson_interactions (organization_id, user_id, lesson_id, video_timestamp, event_type, metadata)
VALUES ($1, $2, $3, $4, $5, $6)"
)
.bind(org_ctx.id)
.bind(claims.sub)
.bind(lesson_id)
.bind(payload.video_timestamp)
.bind(payload.event_type)
.bind(payload.metadata)
.execute(&pool)
.await
.map_err(|e| {
tracing::error!("Failed to record interaction: {}", e);
StatusCode::INTERNAL_SERVER_ERROR
})?;
Ok(StatusCode::CREATED)
}
pub async fn get_lesson_heatmap(
Org(org_ctx): Org,
Path(lesson_id): Path<Uuid>,
State(pool): State<PgPool>,
) -> Result<Json<Vec<HeatmapPoint>>, StatusCode> {
let heatmap = sqlx::query_as::<_, HeatmapPoint>(
"SELECT floor(video_timestamp)::int as second, count(*)::bigint as count
FROM lesson_interactions
WHERE lesson_id = $1 AND organization_id = $2 AND video_timestamp IS NOT NULL
GROUP BY second
ORDER BY second",
)
.bind(lesson_id)
.bind(org_ctx.id)
.fetch_all(&pool)
.await
.map_err(|e| {
tracing::error!("Failed to fetch heatmap: {}", e);
StatusCode::INTERNAL_SERVER_ERROR
})?;
Ok(Json(heatmap))
}
pub async fn get_notifications(
Org(org_ctx): Org,
claims: Claims,
State(pool): State<PgPool>,
) -> Result<Json<Vec<Notification>>, StatusCode> {
let notifications = sqlx::query_as::<_, Notification>(
"SELECT * FROM notifications WHERE user_id = $1 AND organization_id = $2 ORDER BY created_at DESC LIMIT 50"
)
.bind(claims.sub)
.bind(org_ctx.id)
.fetch_all(&pool)
.await
.map_err(|e| {
tracing::error!("Failed to fetch notifications: {}", e);
StatusCode::INTERNAL_SERVER_ERROR
})?;
Ok(Json(notifications))
}
pub async fn mark_notification_as_read(
Org(org_ctx): Org,
claims: Claims,
Path(id): Path<Uuid>,
State(pool): State<PgPool>,
) -> Result<StatusCode, StatusCode> {
sqlx::query(
"UPDATE notifications SET is_read = TRUE WHERE id = $1 AND user_id = $2 AND organization_id = $3"
)
.bind(id)
.bind(claims.sub)
.bind(org_ctx.id)
.execute(&pool)
.await
.map_err(|e| {
tracing::error!("Failed to mark notification as read: {}", e);
StatusCode::INTERNAL_SERVER_ERROR
})?;
Ok(StatusCode::OK)
}
pub async fn check_deadlines_and_notify(pool: PgPool) {
let result = sqlx::query(
"INSERT INTO notifications (organization_id, user_id, title, message, notification_type, link_url)
SELECT
l.organization_id,
e.user_id,
'Fecha límite próxima: ' || l.title,
'La lección \"' || l.title || '\" del curso \"' || c.title || '\" vence en menos de 24 horas.',
'deadline',
'/courses/' || c.id || '/lessons/' || l.id
FROM enrollments e
JOIN lessons l ON l.course_id = e.course_id
JOIN courses c ON c.id = l.course_id
WHERE l.due_date BETWEEN NOW() AND NOW() + INTERVAL '24 hours'
AND NOT EXISTS (
SELECT 1 FROM notifications n
WHERE n.user_id = e.user_id
AND n.notification_type = 'deadline'
AND n.link_url = '/courses/' || c.id || '/lessons/' || l.id
AND n.created_at > NOW() - INTERVAL '48 hours'
)"
)
.execute(&pool)
.await;
if let Err(e) = result {
tracing::error!("Failed to run deadline notifications: {}", e);
}
}
pub async fn update_user(
Org(org_ctx): Org,
claims: common::auth::Claims,
+19
View File
@@ -29,6 +29,15 @@ async fn main() {
.await
.expect("Failed to run migrations");
// Start background task for deadline notifications
let pool_clone = pool.clone();
tokio::spawn(async move {
loop {
handlers::check_deadlines_and_notify(pool_clone.clone()).await;
tokio::time::sleep(tokio::time::Duration::from_secs(3600)).await; // Every hour
}
});
let cors = CorsLayer::new()
.allow_origin(Any)
.allow_methods(Any)
@@ -58,6 +67,16 @@ async fn main() {
)
.route("/users/{id}", post(handlers::update_user))
.route("/analytics/leaderboard", get(handlers::get_leaderboard))
.route(
"/lessons/{id}/interactions",
post(handlers::record_interaction),
)
.route("/lessons/{id}/heatmap", get(handlers::get_lesson_heatmap))
.route("/notifications", get(handlers::get_notifications))
.route(
"/notifications/{id}/read",
post(handlers::mark_notification_as_read),
)
.route_layer(middleware::from_fn(
common::middleware::org_extractor_middleware,
));