From 23d761bada64348fd41526ecd751c8d38cc4257c Mon Sep 17 00:00:00 2001 From: Nurfog Date: Tue, 27 Jan 2026 11:13:08 -0300 Subject: [PATCH] feat: mensajes --- README.md | 24 +++ roadmap.md | 14 +- .../20260127000001_course_announcements.sql | 33 +++ .../lms-service/src/handlers_announcements.rs | 203 ++++++++++++++++++ .../lms-service/src/handlers_discussions.rs | 4 +- services/lms-service/src/main.rs | 8 +- shared/common/src/models.rs | 32 +++ web/experience/src/app/courses/[id]/page.tsx | 6 + .../src/components/AnnouncementCard.tsx | 83 +++++++ .../src/components/AnnouncementsList.tsx | 117 ++++++++++ .../src/components/NewAnnouncementModal.tsx | 131 +++++++++++ web/experience/src/lib/api.ts | 55 +++++ 12 files changed, 700 insertions(+), 10 deletions(-) create mode 100644 services/lms-service/migrations/20260127000001_course_announcements.sql create mode 100644 services/lms-service/src/handlers_announcements.rs create mode 100644 web/experience/src/components/AnnouncementCard.tsx create mode 100644 web/experience/src/components/AnnouncementsList.tsx create mode 100644 web/experience/src/components/NewAnnouncementModal.tsx diff --git a/README.md b/README.md index b2949a6..3e85a8f 100644 --- a/README.md +++ b/README.md @@ -501,6 +501,29 @@ curl -X POST "http://localhost:3002/posts/{post_id}/vote" \ --- +### 5. Course Announcements (Anuncios) + +| Action | Method | Endpoint | Description | +|--------|--------|----------|-------------| +| List | GET | `/courses/{id}/announcements` | Get all announcements for a course | +| Create | POST | `/courses/{id}/announcements` | Create a new announcement (Instructor/Admin only) | +| Update | PUT | `/announcements/{id}` | Update an announcement (Instructor/Admin only) | +| Delete | DELETE| `/announcements/{id}` | Delete an announcement (Instructor/Admin only) | + +#### Create Announcement Example +```bash +curl -X POST http://localhost:3002/courses/{course_id}/announcements \ + -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" \ + -d '{ + "title": "Bienvenida al curso", + "content": "Bienvenidos a todos a este emocionante curso.", + "is_pinned": true + }' +``` + +--- + ### 6. Multi-tenancy and Global Management (Super Admin) OpenCCB is built for multi-tenancy. Organizations are isolated, but a **Super Admin** can manage everything. @@ -556,6 +579,7 @@ Obtiene una lista de todas las organizaciones registradas. - **Personalized AI Feedback**: Motivational and instructional feedback generated uniquely for each student's results. - **Color-Coded Navigation**: Real-time visual progress indicators for lessons and modules (Green/Yellow/Red). - **Discussion Forums**: Complete forum system with threaded replies, voting, instructor moderation, and subscriptions. +- **Course Announcements**: Instructor-to-student communication system with automatic notifications and pinning functionality. - **Split Authentication**: Separate login flows for personal users and enterprise organizations with SSO support. ## 馃搫 Licencia diff --git a/roadmap.md b/roadmap.md index 177448b..c6653b0 100644 --- a/roadmap.md +++ b/roadmap.md @@ -177,9 +177,9 @@ - [x] Backend API (10 endpoints para gesti贸n completa) - [x] Permisos diferenciados (estudiante vs instructor) - [x] Sistema de votaci贸n y endorsement - - [ ] Frontend (componentes React) - - [ ] Integraci贸n con notificaciones -- [ ] **Course Announcements**: Sistema de anuncios de instructores con notificaciones. + - [x] Frontend (componentes React) + - [x] Integraci贸n con notificaciones +- [x] **Course Announcements**: Sistema de anuncios de instructores con notificaciones. - [ ] **Student Notes**: Anotaciones personales por lecci贸n con exportaci贸n a PDF. - [ ] **Peer Assessment**: Evaluaci贸n entre pares con r煤bricas configurables. - [ ] **Cohorts & Groups**: Segmentaci贸n de estudiantes con contenido espec铆fico. @@ -194,9 +194,9 @@ --- -**Estado Actual**: La plataforma cuenta con un motor de IA avanzado, gesti贸n multi-tenant completa, tutor铆a inteligente con memoria hist贸rica, una **interfaz 100% responsiva**, flujos de autenticaci贸n diferenciados y **sistema de foros de discusi贸n funcional (backend completo)**. +**Estado Actual**: La plataforma cuenta con un motor de IA avanzado, gesti贸n multi-tenant completa, tutor铆a inteligente con memoria hist贸rica, una **interfaz 100% responsiva**, flujos de autenticaci贸n diferenciados, **sistema de foros de discusi贸n funcional** y **gesti贸n de anuncios del curso con notificaciones autom谩ticas**. **Pr贸ximas Prioridades**: -1. **Discussion Forums Frontend**: Componentes React para interfaz de foros. -2. **Course Announcements**: Comunicaci贸n instructor-estudiante. -3. **Student Notes**: Anotaciones personales exportables. +1. **Course Wiki**: Espacio colaborativo para documentaci贸n de cursos. +2. **Student Notes**: Anotaciones personales exportables. +3. **Course Teams**: Permisos granulares para m煤ltiples instructores. diff --git a/services/lms-service/migrations/20260127000001_course_announcements.sql b/services/lms-service/migrations/20260127000001_course_announcements.sql new file mode 100644 index 0000000..33a2ac4 --- /dev/null +++ b/services/lms-service/migrations/20260127000001_course_announcements.sql @@ -0,0 +1,33 @@ +-- Course Announcements System +-- Allows instructors to post announcements to students with automatic notifications + +CREATE TABLE IF NOT EXISTS course_announcements ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + organization_id UUID NOT NULL REFERENCES organizations(id) ON DELETE CASCADE, + course_id UUID NOT NULL REFERENCES courses(id) ON DELETE CASCADE, + author_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + title TEXT NOT NULL, + content TEXT NOT NULL, + is_pinned BOOLEAN DEFAULT false, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- Indexes for performance +CREATE INDEX idx_announcements_course ON course_announcements(course_id); +CREATE INDEX idx_announcements_org ON course_announcements(organization_id); +CREATE INDEX idx_announcements_pinned ON course_announcements(is_pinned, created_at DESC); + +-- Trigger to update updated_at timestamp +CREATE OR REPLACE FUNCTION update_announcement_timestamp() +RETURNS TRIGGER AS $$ +BEGIN + NEW.updated_at = NOW(); + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +CREATE TRIGGER announcement_updated_at + BEFORE UPDATE ON course_announcements + FOR EACH ROW + EXECUTE FUNCTION update_announcement_timestamp(); diff --git a/services/lms-service/src/handlers_announcements.rs b/services/lms-service/src/handlers_announcements.rs new file mode 100644 index 0000000..ac20571 --- /dev/null +++ b/services/lms-service/src/handlers_announcements.rs @@ -0,0 +1,203 @@ +use axum::{ + Json, + extract::{Path, State}, + http::StatusCode, +}; +use common::auth::Claims; +use common::middleware::Org; +use common::models::{CourseAnnouncement, AnnouncementWithAuthor}; +use serde::Deserialize; +use sqlx::PgPool; +use uuid::Uuid; + +// ========== Request/Response DTOs ========== + +#[derive(Deserialize)] +pub struct CreateAnnouncementPayload { + pub title: String, + pub content: String, + pub is_pinned: Option, +} + +#[derive(Deserialize)] +pub struct UpdateAnnouncementPayload { + pub title: Option, + pub content: Option, + pub is_pinned: Option, +} + +// ========== HANDLERS ========== + +pub async fn list_announcements( + Org(org_ctx): Org, + Path(course_id): Path, + State(pool): State, +) -> Result>, (StatusCode, String)> { + let announcements = sqlx::query_as::<_, AnnouncementWithAuthor>( + "SELECT + a.*, + u.full_name as author_name, + u.avatar_url as author_avatar + FROM course_announcements a + LEFT JOIN users u ON a.author_id = u.id + WHERE a.course_id = $1 AND a.organization_id = $2 + ORDER BY a.is_pinned DESC, a.created_at DESC" + ) + .bind(course_id) + .bind(org_ctx.id) + .fetch_all(&pool) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + + Ok(Json(announcements)) +} + +pub async fn create_announcement( + Org(org_ctx): Org, + claims: Claims, + Path(course_id): Path, + State(pool): State, + Json(payload): Json, +) -> Result, (StatusCode, String)> { + // Check if user is instructor or admin + let user = sqlx::query_as::<_, (String,)>("SELECT role FROM users WHERE id = $1") + .bind(claims.sub) + .fetch_one(&pool) + .await + .map_err(|_| (StatusCode::UNAUTHORIZED, "User not found".to_string()))?; + + if user.0 != "instructor" && user.0 != "admin" { + return Err((StatusCode::FORBIDDEN, "Only instructors can create announcements".to_string())); + } + + // Create announcement + let announcement = sqlx::query_as::<_, CourseAnnouncement>( + "INSERT INTO course_announcements (organization_id, course_id, author_id, title, content, is_pinned) + VALUES ($1, $2, $3, $4, $5, $6) + RETURNING *" + ) + .bind(org_ctx.id) + .bind(course_id) + .bind(claims.sub) + .bind(&payload.title) + .bind(&payload.content) + .bind(payload.is_pinned.unwrap_or(false)) + .fetch_one(&pool) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + + // Get all enrolled students for notifications + let enrolled_students = sqlx::query_as::<_, (Uuid,)>( + "SELECT user_id FROM enrollments WHERE course_id = $1 AND user_id != $2" + ) + .bind(course_id) + .bind(claims.sub) // Exclude the announcement author + .fetch_all(&pool) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + + // Create notification for each enrolled student + for (student_id,) in enrolled_students { + let notification_title = format!("Nuevo Anuncio: {}", payload.title); + let notification_message = if payload.content.len() > 100 { + format!("{}...", &payload.content[..100]) + } else { + payload.content.clone() + }; + + 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(student_id) + .bind(notification_title) + .bind(notification_message) + .bind("announcement") + .bind(format!("/courses/{}#announcements", course_id)) + .execute(&pool) + .await; + } + + Ok(Json(announcement)) +} + +pub async fn update_announcement( + Org(org_ctx): Org, + claims: Claims, + Path(announcement_id): Path, + State(pool): State, + Json(payload): Json, +) -> Result, (StatusCode, String)> { + // Check if user is instructor or admin + let user = sqlx::query_as::<_, (String,)>("SELECT role FROM users WHERE id = $1") + .bind(claims.sub) + .fetch_one(&pool) + .await + .map_err(|_| (StatusCode::UNAUTHORIZED, "User not found".to_string()))?; + + if user.0 != "instructor" && user.0 != "admin" { + return Err((StatusCode::FORBIDDEN, "Only instructors can update announcements".to_string())); + } + + // Get current announcement to verify ownership + let current = sqlx::query_as::<_, CourseAnnouncement>( + "SELECT * FROM course_announcements WHERE id = $1 AND organization_id = $2" + ) + .bind(announcement_id) + .bind(org_ctx.id) + .fetch_one(&pool) + .await + .map_err(|_| (StatusCode::NOT_FOUND, "Announcement not found".to_string()))?; + + let title = payload.title.unwrap_or(current.title); + let content = payload.content.unwrap_or(current.content); + let is_pinned = payload.is_pinned.unwrap_or(current.is_pinned); + + let announcement = sqlx::query_as::<_, CourseAnnouncement>( + "UPDATE course_announcements + SET title = $1, content = $2, is_pinned = $3 + WHERE id = $4 AND organization_id = $5 + RETURNING *" + ) + .bind(title) + .bind(content) + .bind(is_pinned) + .bind(announcement_id) + .bind(org_ctx.id) + .fetch_one(&pool) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + + Ok(Json(announcement)) +} + +pub async fn delete_announcement( + Org(org_ctx): Org, + claims: Claims, + Path(announcement_id): Path, + State(pool): State, +) -> Result { + // Check if user is instructor or admin + let user = sqlx::query_as::<_, (String,)>("SELECT role FROM users WHERE id = $1") + .bind(claims.sub) + .fetch_one(&pool) + .await + .map_err(|_| (StatusCode::UNAUTHORIZED, "User not found".to_string()))?; + + if user.0 != "instructor" && user.0 != "admin" { + return Err((StatusCode::FORBIDDEN, "Only instructors can delete announcements".to_string())); + } + + sqlx::query( + "DELETE FROM course_announcements + WHERE id = $1 AND organization_id = $2" + ) + .bind(announcement_id) + .bind(org_ctx.id) + .execute(&pool) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + + Ok(StatusCode::OK) +} diff --git a/services/lms-service/src/handlers_discussions.rs b/services/lms-service/src/handlers_discussions.rs index dbb3d5f..6129925 100644 --- a/services/lms-service/src/handlers_discussions.rs +++ b/services/lms-service/src/handlers_discussions.rs @@ -6,10 +6,10 @@ use axum::{ use common::auth::Claims; use common::middleware::Org; use common::models::{ - DiscussionThread, DiscussionPost, DiscussionVote, DiscussionSubscription, + DiscussionThread, DiscussionPost, ThreadWithAuthor, PostWithAuthor, }; -use serde::{Deserialize, Serialize}; +use serde::Deserialize; use sqlx::PgPool; use uuid::Uuid; diff --git a/services/lms-service/src/main.rs b/services/lms-service/src/main.rs index 5044232..476064f 100644 --- a/services/lms-service/src/main.rs +++ b/services/lms-service/src/main.rs @@ -1,10 +1,11 @@ mod db_util; mod handlers; mod handlers_discussions; +mod handlers_announcements; use axum::{ Router, middleware, - routing::{get, post}, + routing::{get, post, put, delete}, }; use dotenvy::dotenv; use sqlx::postgres::PgPoolOptions; @@ -100,6 +101,11 @@ async fn main() { .route("/posts/{id}/vote", post(handlers_discussions::vote_post)) .route("/discussions/{id}/subscribe", post(handlers_discussions::subscribe_thread)) .route("/discussions/{id}/unsubscribe", post(handlers_discussions::unsubscribe_thread)) + // Announcements + .route("/courses/{id}/announcements", get(handlers_announcements::list_announcements)) + .route("/courses/{id}/announcements", post(handlers_announcements::create_announcement)) + .route("/announcements/{id}", put(handlers_announcements::update_announcement)) + .route("/announcements/{id}", delete(handlers_announcements::delete_announcement)) .route_layer(middleware::from_fn( common::middleware::org_extractor_middleware, )); diff --git a/shared/common/src/models.rs b/shared/common/src/models.rs index 9d0ad00..d70bcfa 100644 --- a/shared/common/src/models.rs +++ b/shared/common/src/models.rs @@ -388,6 +388,38 @@ pub struct PostWithAuthor { pub replies: Vec, } +// Course Announcements +#[derive(Debug, Serialize, Deserialize, sqlx::FromRow, Clone)] +pub struct CourseAnnouncement { + pub id: Uuid, + pub organization_id: Uuid, + pub course_id: Uuid, + pub author_id: Uuid, + pub title: String, + pub content: String, + pub is_pinned: bool, + pub created_at: DateTime, + pub updated_at: DateTime, +} + +#[derive(Debug, Serialize, Deserialize, sqlx::FromRow, Clone)] +pub struct AnnouncementWithAuthor { + // Announcement fields + pub id: Uuid, + pub organization_id: Uuid, + pub course_id: Uuid, + pub author_id: Uuid, + pub title: String, + pub content: String, + pub is_pinned: bool, + pub created_at: DateTime, + pub updated_at: DateTime, + // Author info + pub author_name: String, + pub author_avatar: Option, +} + + #[cfg(test)] mod tests { diff --git a/web/experience/src/app/courses/[id]/page.tsx b/web/experience/src/app/courses/[id]/page.tsx index 45c131e..8d36178 100644 --- a/web/experience/src/app/courses/[id]/page.tsx +++ b/web/experience/src/app/courses/[id]/page.tsx @@ -7,6 +7,7 @@ import Link from "next/link"; import { BookOpen, ChevronRight, PlayCircle, Calendar, Clock, Info } from "lucide-react"; import { useAuth } from "@/context/AuthContext"; import DiscussionBoard from "@/components/DiscussionBoard"; +import { AnnouncementsList } from "@/components/AnnouncementsList"; export default function CourseOutlinePage({ params }: { params: { id: string } }) { const { user } = useAuth(); @@ -199,6 +200,11 @@ export default function CourseOutlinePage({ params }: { params: { id: string } } )} + {/* Announcements Section */} +
+ +
+
{courseData.modules.map((module, idx) => (
diff --git a/web/experience/src/components/AnnouncementCard.tsx b/web/experience/src/components/AnnouncementCard.tsx new file mode 100644 index 0000000..7ec4c8a --- /dev/null +++ b/web/experience/src/components/AnnouncementCard.tsx @@ -0,0 +1,83 @@ +import React, { useState } from 'react'; +import { formatDistanceToNow } from 'date-fns'; +import { es } from 'date-fns/locale'; +import { AnnouncementWithAuthor, lmsApi } from '../lib/api'; +import { Pin, Trash2, Edit2, MoreVertical } from 'lucide-react'; + +interface AnnouncementCardProps { + announcement: AnnouncementWithAuthor; + isInstructor: boolean; + onUpdate: () => void; +} + +export const AnnouncementCard: React.FC = ({ + announcement, + isInstructor, + onUpdate +}) => { + const [isDeleting, setIsDeleting] = useState(false); + + const handleDelete = async () => { + if (!confirm('驴Est谩s seguro de que deseas eliminar este anuncio?')) return; + + setIsDeleting(true); + try { + await lmsApi.deleteAnnouncement(announcement.id); + onUpdate(); + } catch (error) { + console.error('Error deleting announcement:', error); + alert('Error al eliminar el anuncio'); + } finally { + setIsDeleting(false); + } + }; + + return ( +
+ {announcement.is_pinned && ( +
+ +
+ )} + +
+
+
+ {announcement.author_avatar ? ( + {announcement.author_name} + ) : ( + announcement.author_name.charAt(0) + )} +
+
+

{announcement.author_name}

+

+ {formatDistanceToNow(new Date(announcement.created_at), { addSuffix: true, locale: es })} +

+
+
+ + {isInstructor && ( +
+ +
+ )} +
+ +

{announcement.title}

+
+ {announcement.content} +
+
+ ); +}; diff --git a/web/experience/src/components/AnnouncementsList.tsx b/web/experience/src/components/AnnouncementsList.tsx new file mode 100644 index 0000000..b911dce --- /dev/null +++ b/web/experience/src/components/AnnouncementsList.tsx @@ -0,0 +1,117 @@ +import React, { useState, useEffect } from 'react'; +import { AnnouncementWithAuthor, lmsApi } from '../lib/api'; +import { AnnouncementCard } from './AnnouncementCard'; +import { NewAnnouncementModal } from './NewAnnouncementModal'; +import { Megaphone, Plus, Search, Loader2 } from 'lucide-react'; + +interface AnnouncementsListProps { + courseId: string; + isInstructor: boolean; +} + +export const AnnouncementsList: React.FC = ({ courseId, isInstructor }) => { + const [announcements, setAnnouncements] = useState([]); + const [loading, setLoading] = useState(true); + const [searchTerm, setSearchTerm] = useState(''); + const [showNewModal, setShowNewModal] = useState(false); + + const fetchAnnouncements = async () => { + try { + setLoading(true); + const data = await lmsApi.getAnnouncements(courseId); + setAnnouncements(data); + } catch (error) { + console.error('Error fetching announcements:', error); + } finally { + setLoading(false); + } + }; + + useEffect(() => { + fetchAnnouncements(); + }, [courseId]); + + const filteredAnnouncements = announcements.filter(a => + a.title.toLowerCase().includes(searchTerm.toLowerCase()) || + a.content.toLowerCase().includes(searchTerm.toLowerCase()) + ); + + return ( +
+
+
+
+ +
+
+

Anuncios del Curso

+

Mantente al d铆a con las 煤ltimas noticias

+
+
+ +
+
+ + setSearchTerm(e.target.value)} + className="pl-10 pr-4 py-2 bg-white/5 border border-white/10 rounded-xl text-white placeholder:text-gray-500 focus:outline-none focus:border-primary-500/50 transition-colors w-full sm:w-64" + /> +
+ {isInstructor && ( + + )} +
+
+ + {loading ? ( +
+ +

Cargando anuncios...

+
+ ) : filteredAnnouncements.length > 0 ? ( +
+ {filteredAnnouncements.map((announcement) => ( + + ))} +
+ ) : ( +
+
+ +
+

No hay anuncios

+

+ {searchTerm + ? `No se encontraron anuncios que coincidan con "${searchTerm}"` + : "A煤n no se han publicado anuncios en este curso."} +

+
+ )} + + {showNewModal && ( + setShowNewModal(false)} + onSuccess={() => { + setShowNewModal(false); + fetchAnnouncements(); + }} + /> + )} +
+ ); +}; diff --git a/web/experience/src/components/NewAnnouncementModal.tsx b/web/experience/src/components/NewAnnouncementModal.tsx new file mode 100644 index 0000000..938004a --- /dev/null +++ b/web/experience/src/components/NewAnnouncementModal.tsx @@ -0,0 +1,131 @@ +import React, { useState } from 'react'; +import { lmsApi } from '../lib/api'; +import { X, Send, AlertTriangle } from 'lucide-react'; + +interface NewAnnouncementModalProps { + courseId: string; + onClose: () => void; + onSuccess: () => void; +} + +export const NewAnnouncementModal: React.FC = ({ + courseId, + onClose, + onSuccess, +}) => { + const [title, setTitle] = useState(''); + const [content, setContent] = useState(''); + const [isPinned, setIsPinned] = useState(false); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + if (!title.trim() || !content.trim()) return; + + setLoading(true); + setError(null); + try { + await lmsApi.createAnnouncement(courseId, { + title: title.trim(), + content: content.trim(), + is_pinned: isPinned, + }); + onSuccess(); + } catch (err: any) { + console.error('Error creating announcement:', err); + setError('Error al crear el anuncio. Por favor, int茅ntalo de nuevo.'); + } finally { + setLoading(false); + } + }; + + return ( +
+
+
+

+ + Publicar Nuevo Anuncio +

+ +
+ +
+ {error && ( +
+ +

{error}

+
+ )} + +
+ + setTitle(e.target.value)} + placeholder="Ej: Nuevos materiales disponibles" + className="w-full px-4 py-3 bg-white/5 border border-white/10 rounded-xl text-white placeholder:text-gray-500 focus:outline-none focus:border-primary-500/50 transition-colors" + /> +
+ +
+ +