From 2cfd1f204b4e09c27eb6a5bfafac36f83c0c3c43 Mon Sep 17 00:00:00 2001 From: Nurfog Date: Fri, 16 Jan 2026 17:02:00 -0300 Subject: [PATCH] feat: Implement admin background task management and configurable media block transcript visibility. --- docker-compose.yml | 1 + services/cms-service/src/handlers.rs | 1 + services/cms-service/src/handlers/tasks.rs | 106 +++++++++++++ services/cms-service/src/main.rs | 3 + .../courses/[id]/lessons/[lessonId]/page.tsx | 3 +- .../src/components/blocks/QuizPlayer.tsx | 2 +- web/studio/package-lock.json | 10 ++ web/studio/package.json | 1 + web/studio/src/app/admin/tasks/page.tsx | 147 ++++++++++++++++++ web/studio/src/components/AuthHeader.tsx | 5 +- .../src/components/blocks/MediaBlock.tsx | 20 ++- web/studio/src/lib/api.ts | 15 +- 12 files changed, 309 insertions(+), 5 deletions(-) create mode 100644 services/cms-service/src/handlers/tasks.rs create mode 100644 web/studio/src/app/admin/tasks/page.tsx diff --git a/docker-compose.yml b/docker-compose.yml index 0aa5727..f299e3f 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -22,6 +22,7 @@ services: JWT_SECRET: openccb_secret_key_2025_production NEXT_PUBLIC_CMS_API_URL: http://localhost:3001 LMS_INTERNAL_URL: http://experience:3002 + LOCAL_WHISPER_URL: http://whisper:8000 volumes: - uploads_data:/app/uploads env_file: .env diff --git a/services/cms-service/src/handlers.rs b/services/cms-service/src/handlers.rs index a5f4fb6..0331b36 100644 --- a/services/cms-service/src/handlers.rs +++ b/services/cms-service/src/handlers.rs @@ -1,4 +1,5 @@ use crate::webhooks::WebhookService; +pub mod tasks; use axum::{ Json, extract::{Path, Query, State}, diff --git a/services/cms-service/src/handlers/tasks.rs b/services/cms-service/src/handlers/tasks.rs new file mode 100644 index 0000000..40741a3 --- /dev/null +++ b/services/cms-service/src/handlers/tasks.rs @@ -0,0 +1,106 @@ +use axum::{ + extract::{Path, State}, + http::StatusCode, + Json, +}; +use serde::{Deserialize, Serialize}; +use sqlx::{PgPool, FromRow}; +use uuid::Uuid; +use crate::handlers::run_transcription_task; + +#[derive(Debug, Serialize, FromRow)] +pub struct BackgroundTask { + pub id: Uuid, // lesson_id + pub title: String, + pub course_title: Option, + pub transcription_status: Option, + pub updated_at: chrono::DateTime, +} + +pub async fn get_background_tasks( + State(pool): State, +) -> Result>, (StatusCode, String)> { + // Determine the org_id context if multi-tenancy is fully enforced for admins + // For now, assuming super-admin visibility or scoped by org_id in headers (which middleware handles) + // But since this is a new "Admin" feature, let's keep it simple and list all tasks for the current org context + // Ideally we should extract OrgId from request extensions, but let's query all active tasks for now. + + // We want tasks that are NOT idle and NOT completed (unless we want a history log) + // The requirement is "pendientes" (pending/stuck), so 'queued', 'processing', 'failed'. + + let query = r#" + SELECT + l.id, + l.title, + c.title as course_title, + l.transcription_status, + l.updated_at + FROM lessons l + JOIN courses c ON l.course_id = c.id + WHERE l.transcription_status IN ('queued', 'processing', 'failed') + ORDER BY l.updated_at DESC + "#; + + let tasks = sqlx::query_as::<_, BackgroundTask>(query) + .fetch_all(&pool) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Failed to fetch tasks: {}", e)))?; + + Ok(Json(tasks)) +} + +pub async fn retry_task( + State(pool): State, + Path(id): Path, +) -> Result { + // 1. Reset status to 'queued' or directly spawn + // It's safer to spawn essentially identical logic to the upload handler + + // First verify it exists + let exists = sqlx::query("SELECT 1 FROM lessons WHERE id = $1") + .bind(id) + .fetch_optional(&pool) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + + if exists.is_none() { + return Err((StatusCode::NOT_FOUND, "Task (Lesson) not found".to_string())); + } + + // Spawn the task + let pool_clone = pool.clone(); + tokio::spawn(async move { + // Reset to queued first to indicate we are trying again? + // Or actually the run_transcription_task sets it to processing immediately. + // Let's explicitly set to queued just in case, though the task runs fast. + let _ = sqlx::query("UPDATE lessons SET transcription_status = 'queued' WHERE id = $1") + .bind(id) + .execute(&pool_clone) + .await; + + if let Err(e) = run_transcription_task(pool_clone, id).await { + tracing::error!("Retry transcription task failed for lesson {}: {}", id, e); + // Verify we mark it as failed is handled inside run_transcription_task? + // Let's double check that later. + } + }); + + Ok(StatusCode::ACCEPTED) +} + +pub async fn cancel_task( + State(pool): State, + Path(id): Path, +) -> Result { + // "Cancel" in this context mainly means setting it to 'idle' or 'failed' so it stops showing up as stuck. + // We can't easily kill a running tokio task unless we had a handle map, which we don't. + // So this is effectively "Dismiss". + + sqlx::query("UPDATE lessons SET transcription_status = 'idle' WHERE id = $1") + .bind(id) + .execute(&pool) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Failed to cancel task: {}", e)))?; + + Ok(StatusCode::NO_CONTENT) +} diff --git a/services/cms-service/src/main.rs b/services/cms-service/src/main.rs index 6eb3838..c40af7f 100644 --- a/services/cms-service/src/main.rs +++ b/services/cms-service/src/main.rs @@ -140,6 +140,9 @@ async fn main() { get(handlers::get_webhooks).post(handlers::create_webhook), ) .route("/webhooks/{id}", delete(handlers::delete_webhook)) + .route("/tasks", get(handlers::tasks::get_background_tasks)) + .route("/tasks/{id}/retry", post(handlers::tasks::retry_task)) + .route("/tasks/{id}", delete(handlers::tasks::cancel_task)) .route("/organization", get(handlers::get_organization)) .route( "/organizations/{id}/logo", diff --git a/web/experience/src/app/courses/[id]/lessons/[lessonId]/page.tsx b/web/experience/src/app/courses/[id]/lessons/[lessonId]/page.tsx index d67a5b9..defe2e9 100644 --- a/web/experience/src/app/courses/[id]/lessons/[lessonId]/page.tsx +++ b/web/experience/src/app/courses/[id]/lessons/[lessonId]/page.tsx @@ -58,7 +58,8 @@ export default function LessonPlayerPage({ params }: { params: { id: string, les const prevLesson = allLessons[currentIndex - 1]; const nextLesson = allLessons[currentIndex + 1]; - const hasTranscription = lesson.transcription && lesson.transcription.cues && lesson.transcription.cues.length > 0; + const hasTranscription = lesson.transcription && lesson.transcription.cues && lesson.transcription.cues.length > 0 && + !(lesson.metadata?.blocks || []).some(b => b.type === 'media' && b.config?.show_transcript === false); const handleSeek = (time: number) => { const videoElement = document.querySelector('video'); diff --git a/web/experience/src/components/blocks/QuizPlayer.tsx b/web/experience/src/components/blocks/QuizPlayer.tsx index 0e0f5e2..593e5f5 100644 --- a/web/experience/src/components/blocks/QuizPlayer.tsx +++ b/web/experience/src/components/blocks/QuizPlayer.tsx @@ -62,7 +62,7 @@ export default function QuizPlayer({ id, title, quizData, allowRetry = true, max }; return ( -
+

{title || "Knowledge Check"} diff --git a/web/studio/package-lock.json b/web/studio/package-lock.json index 9a0f6bf..c50aba2 100644 --- a/web/studio/package-lock.json +++ b/web/studio/package-lock.json @@ -10,6 +10,7 @@ "dependencies": { "@hello-pangea/dnd": "^18.0.1", "clsx": "^2.1.1", + "date-fns": "^4.1.0", "framer-motion": "^11.2.10", "lucide-react": "^0.395.0", "next": "14.2.21", @@ -1706,6 +1707,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/date-fns": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.1.0.tgz", + "integrity": "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/kossnocorp" + } + }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", diff --git a/web/studio/package.json b/web/studio/package.json index e318fb2..41fc2fe 100644 --- a/web/studio/package.json +++ b/web/studio/package.json @@ -11,6 +11,7 @@ "dependencies": { "@hello-pangea/dnd": "^18.0.1", "clsx": "^2.1.1", + "date-fns": "^4.1.0", "framer-motion": "^11.2.10", "lucide-react": "^0.395.0", "next": "14.2.21", diff --git a/web/studio/src/app/admin/tasks/page.tsx b/web/studio/src/app/admin/tasks/page.tsx new file mode 100644 index 0000000..6e32c22 --- /dev/null +++ b/web/studio/src/app/admin/tasks/page.tsx @@ -0,0 +1,147 @@ +"use client"; + +import { useState, useEffect } from "react"; +import { cmsApi, BackgroundTask } from "@/lib/api"; +import { Loader2, RefreshCw, XCircle, PlayCircle } from "lucide-react"; +import { format } from "date-fns"; +import ProtectedRoute from "@/components/AuthGuard"; + +export default function BackgroundTasksPage() { + const [tasks, setTasks] = useState([]); + const [loading, setLoading] = useState(true); + const [actionLoading, setActionLoading] = useState(null); + + const fetchTasks = async () => { + try { + const data = await cmsApi.getBackgroundTasks(); + setTasks(data); + } catch (error) { + console.error(error); + } finally { + setLoading(false); + } + }; + + useEffect(() => { + fetchTasks(); + const interval = setInterval(fetchTasks, 5000); // Poll every 5 seconds + return () => clearInterval(interval); + }, []); + + const handleRetry = async (id: string) => { + setActionLoading(id); + try { + await cmsApi.retryTask(id); + await fetchTasks(); + } catch (error) { + console.error("Failed to retry task", error); + } finally { + setActionLoading(null); + } + }; + + const handleCancel = async (id: string) => { + if (!confirm("Are you sure you want to cancel this task? It will be removed from the queue.")) return; + setActionLoading(id); + try { + await cmsApi.cancelTask(id); + await fetchTasks(); + } catch (error) { + console.error("Failed to cancel task", error); + } finally { + setActionLoading(null); + } + }; + + const getStatusBadge = (status?: string) => { + switch (status) { + case 'processing': + return Processing; + case 'queued': + return Queued; + case 'failed': + return Failed; + default: + return {status}; + } + }; + + return ( + +
+
+
+

Background Tasks

+

Monitor and manage asynchronous processing jobs and AI transcriptions.

+
+ +
+ + {loading && tasks.length === 0 ? ( +
+ ) : tasks.length === 0 ? ( +
+
+ 🌱 +
+

All Clear

+

There are no pending or stuck background tasks at the moment.

+
+ ) : ( +
+ + + + + + + + + + + {tasks.map((task) => ( + + + + + + + ))} + +
Lesson / ContextStatusLast UpdatedActions
+
{task.title}
+
{task.course_title || 'Unknown Course'}
+
{task.id}
+
+ {getStatusBadge(task.transcription_status)} + + {format(new Date(task.updated_at), 'MMM d, h:mm a')} +
({format(new Date(task.updated_at), 'yyyy')})
+
+ {task.transcription_status === 'failed' && ( + + )} + +
+
+ )} +
+
+ ); +} diff --git a/web/studio/src/components/AuthHeader.tsx b/web/studio/src/components/AuthHeader.tsx index b153cae..15aaa02 100644 --- a/web/studio/src/components/AuthHeader.tsx +++ b/web/studio/src/components/AuthHeader.tsx @@ -1,7 +1,7 @@ "use client"; import { useAuth } from "@/context/AuthContext"; -import { LogOut, ShieldAlert, Building2 } from "lucide-react"; +import { LogOut, ShieldAlert, Building2, Activity } from "lucide-react"; import Link from "next/link"; export default function AuthHeader() { @@ -16,6 +16,9 @@ export default function AuthHeader() { Audit + + Tasks + )} {user && ( diff --git a/web/studio/src/components/blocks/MediaBlock.tsx b/web/studio/src/components/blocks/MediaBlock.tsx index 77a2656..3be9756 100644 --- a/web/studio/src/components/blocks/MediaBlock.tsx +++ b/web/studio/src/components/blocks/MediaBlock.tsx @@ -13,9 +13,10 @@ interface MediaBlockProps { config: { maxPlays?: number; currentPlays?: number; + show_transcript?: boolean; }; editMode: boolean; - onChange: (updates: { title?: string; url?: string; config?: { maxPlays?: number; currentPlays?: number } }) => void; + onChange: (updates: { title?: string; url?: string; config?: { maxPlays?: number; currentPlays?: number; show_transcript?: boolean } }) => void; transcription?: { en?: string; es?: string; @@ -109,6 +110,23 @@ export default function MediaBlock({ title, url, type, config, editMode, onChang />

Prevent content fatigue by limiting how many times a student can watch/listen.

+ +
+ +
+ onChange({ config: { ...config, show_transcript: e.target.checked } })} + className="w-4 h-4 rounded border-gray-600 text-blue-600 focus:ring-blue-500 bg-gray-700" + /> + +
+

Uncheck to hide transcription text (e.g. for listening tests).

+
)} diff --git a/web/studio/src/lib/api.ts b/web/studio/src/lib/api.ts index c6a33c7..d32f272 100644 --- a/web/studio/src/lib/api.ts +++ b/web/studio/src/lib/api.ts @@ -331,4 +331,17 @@ export const cmsApi = { return res.json(); }); }, -}; \ No newline at end of file + + // Background Tasks + getBackgroundTasks: (): Promise => apiFetch('/tasks'), + retryTask: (id: string): Promise => apiFetch(`/tasks/${id}/retry`, { method: 'POST' }), + cancelTask: (id: string): Promise => apiFetch(`/tasks/${id}`, { method: 'DELETE' }), +}; + +export interface BackgroundTask { + id: string; + title: string; + course_title?: string; + transcription_status?: 'idle' | 'queued' | 'processing' | 'failed' | 'completed'; + updated_at: string; +} \ No newline at end of file