From b1eb23926e45a05e4bcc56c61f497f8217866aba Mon Sep 17 00:00:00 2001 From: Nurfog Date: Sun, 11 Jan 2026 02:34:23 -0300 Subject: [PATCH] feat: database-first refactor, unified architecture and visual developer manual Summary of changes: - Consolidated Studio+CMS and Experience+LMS into unified services. - Moved core business logic (enrollment, grading, auth) to PostgreSQL functions. - Implemented advanced auditing via DB triggers and session context. - Added gamification (XP/Levels/Leaderboards) and logic encapsulation. - Updated installation/diagnostic scripts for the new architecture. - Created a comprehensive Visual Developer Manual in README.md with hardware scaling. --- Cargo.lock | 8 + Cargo.toml | 3 + README.md | 347 ++++---- diagnose_auth.sh | 2 +- docker-compose.yml | 38 +- install.sh | 7 +- roadmap.md | 9 +- services/cms-service/Cargo.toml | 3 + services/cms-service/Dockerfile | 23 - .../20260111000000_gamification_users.sql | 4 + .../migrations/20260111000001_webhooks.sql | 14 + .../20260111000002_advanced_auditing.sql | 130 +++ .../20260111000005_crud_functions.sql | 239 ++++++ services/cms-service/src/db_util.rs | 43 + services/cms-service/src/handlers.rs | 751 ++++++++++++++---- services/cms-service/src/main.rs | 16 +- services/cms-service/src/webhooks.rs | 93 +++ services/lms-service/Dockerfile | 23 - .../migrations/20260111000002_lms_logic.sql | 138 ++++ .../20260111000003_lms_webhooks.sql | 14 + services/lms-service/src/db_util.rs | 40 + services/lms-service/src/handlers.rs | 285 +++++-- services/lms-service/src/main.rs | 34 +- shared/common/Cargo.toml | 5 + shared/common/src/lib.rs | 5 +- shared/common/src/models.rs | 30 + shared/common/src/webhooks.rs | 84 ++ validate_auth.sh | 4 +- web/experience/Dockerfile | 41 +- web/experience/README.md | 36 - web/experience/entrypoint.sh | 7 + web/experience/src/app/page.tsx | 92 ++- web/experience/src/components/Leaderboard.tsx | 77 ++ web/experience/src/lib/api.ts | 13 +- web/studio/Dockerfile | 40 +- web/studio/README.md | 36 - web/studio/entrypoint.sh | 7 + .../courses/[id]/analytics/advanced/page.tsx | 194 +++++ .../src/app/courses/[id]/analytics/page.tsx | 15 +- web/studio/src/app/settings/webhooks/page.tsx | 250 ++++++ web/studio/src/components/Navbar.tsx | 9 +- web/studio/src/lib/api.ts | 40 + 42 files changed, 2661 insertions(+), 588 deletions(-) delete mode 100644 services/cms-service/Dockerfile create mode 100644 services/cms-service/migrations/20260111000000_gamification_users.sql create mode 100644 services/cms-service/migrations/20260111000001_webhooks.sql create mode 100644 services/cms-service/migrations/20260111000002_advanced_auditing.sql create mode 100644 services/cms-service/migrations/20260111000005_crud_functions.sql create mode 100644 services/cms-service/src/db_util.rs create mode 100644 services/cms-service/src/webhooks.rs delete mode 100644 services/lms-service/Dockerfile create mode 100644 services/lms-service/migrations/20260111000002_lms_logic.sql create mode 100644 services/lms-service/migrations/20260111000003_lms_webhooks.sql create mode 100644 services/lms-service/src/db_util.rs create mode 100644 shared/common/src/webhooks.rs delete mode 100644 web/experience/README.md create mode 100644 web/experience/entrypoint.sh create mode 100644 web/experience/src/components/Leaderboard.tsx delete mode 100644 web/studio/README.md create mode 100644 web/studio/entrypoint.sh create mode 100644 web/studio/src/app/courses/[id]/analytics/advanced/page.tsx create mode 100644 web/studio/src/app/settings/webhooks/page.tsx diff --git a/Cargo.lock b/Cargo.lock index a1b87d0..8b9dc8e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -232,10 +232,13 @@ dependencies = [ "chrono", "common", "dotenvy", + "hex", + "hmac", "jsonwebtoken", "reqwest", "serde", "serde_json", + "sha2", "sqlx", "tokio", "tower-http", @@ -251,10 +254,15 @@ dependencies = [ "axum", "bcrypt", "chrono", + "hex", + "hmac", "jsonwebtoken", + "reqwest", "serde", "serde_json", + "sha2", "sqlx", + "tracing", "uuid", ] diff --git a/Cargo.toml b/Cargo.toml index bdfb744..8d3d946 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -26,3 +26,6 @@ bcrypt = "0.17" dotenvy = "0.15" tower-http = { version = "0.6", features = ["cors", "trace", "fs"] } reqwest = { version = "0.12", features = ["json", "multipart"] } +hmac = "0.12" +sha2 = "0.10" +hex = "0.4" diff --git a/README.md b/README.md index e4d1061..6b231be 100644 --- a/README.md +++ b/README.md @@ -2,196 +2,269 @@ OpenCCB es una infraestructura de código abierto para plataformas de gestión de aprendizaje y contenido (LMS/CMS), construida con rendimiento, seguridad y escalabilidad en mente. -## 🚀 Estado del Proyecto +## 🚀 Arquitectura Consolidada -El sistema se encuentra en una fase avanzada (**Phase 6 en progreso**), ofreciendo una infraestructura multi-inquilino de alto rendimiento, gestión de marcas por organización (branding), autenticación segura y análisis detallado de datos. +El proyecto ha sido optimizado para reducir la complejidad de la infraestructura, consolidando los servicios de backend con sus respectivos frontends en contenedores unificados: -Consulta el archivo [ROADMAP.md](./roadmap.md) para ver el desglose detallado de funcionalidades. +1. **Studio + CMS (Puerto 3000/3001)**: + - **Frontend**: Next.js app para administración y creación de contenido. + - **Backend**: API de Rust para gestión (CMS). +2. **Experience + LMS (Puerto 3003/3002)**: + - **Frontend**: Next.js app para la experiencia del estudiante. + - **Backend**: API de Rust para entrega de cursos y calificaciones (LMS). +3. **Database**: PostgreSQL compartido. +4. **AI Services**: Faster-Whisper para transcripción automática. -## 🛠 Stack Tecnológico +## � Requisitos del Sistema -- **Core**: Rust (Edition 2024) -- **API Framework**: Axum -- **Base de Datos**: PostgreSQL (con `sqlx`) -- **Autenticación**: JWT + RBAC (Roles: Admin, Instructor, Student) -- **Infraestructura**: Docker & Docker Compose +OpenCCB es altamente escalable. A continuación se detallan los requisitos recomendados según la carga de usuarios concurrentes: -## 🔌 API Reference (CMS Service) +| Componente | **Pequeño (100 u.)** | **Mediano (500 u.)** | **Grande (1000+ u.)** | +| :--- | :--- | :--- | :--- | +| **CPU** | 4 vCPUs | 8 vCPUs (AVX2) | 16+ vCPUs | +| **RAM** | 8 GB | 16 GB | 32 GB+ | +| **Almacenamiento** | 50 GB SSD | 200 GB NVMe | 500 GB+ NVMe | +| **AI (Opcional)** | N/A (Solo CPU) | NVIDIA 8GB+ VRAM | Multi-GPU / Cloud API | +| **OS** | Ubuntu 22.04 LTS | Ubuntu 22.04+ | Cloud Managed (K8s) | -El servicio CMS expone una API RESTful en el puerto `3001`. A continuación se detallan los contratos de los endpoints principales. +> [!NOTE] +> Los requisitos de AI son específicos para la función de transcripción local (Whisper). Si se utiliza una API externa, el requisito de GPU desaparece. -### 🔐 Autenticación +## �🛠 Stack Tecnológico -#### Registrar Usuario -- **URL**: `POST /auth/register` -- **Descripción**: Crea una nueva cuenta de usuario. -- **Body (JSON)**: - ```json - { - "email": "string (email format)", - "password": "string (min 8 chars)", - "role": "string ('instructor' | 'student')", - "organization_name": "string (optional)" - } - ``` +- **Backend**: Rust (Edition 2024), Axum, SQLx. +- **Frontend**: React, Next.js (App Router), Tailwind CSS, Lucide React. +- **Base de Datos**: PostgreSQL 16. +- **Infraestructura**: Docker & Docker Compose. +- **IA**: Faster-Whisper (Transcriptor de video). -#### Iniciar Sesión -- **URL**: `POST /auth/login` -- **Descripción**: Autentica un usuario y devuelve un token JWT. -- **Body (JSON)**: +## 📦 Guía de Inicio Rápido + +### Requisitos Previos +- Docker y Docker Compose. +- Node.js 18+ (para desarrollo local). +- Rust (para desarrollo local). + +### Ejecución con Docker (Recomendado) +```bash +docker-compose up --build +``` +Esto iniciará todos los servicios: +- **Studio**: [http://localhost:3000](http://localhost:3000) +- **Experience**: [http://localhost:3003](http://localhost:3003) + +### Desarrollo Local + +#### Studio & CMS +```bash +# Iniciar backend CMS +cd services/cms-service +cargo run + +# Iniciar frontend Studio +cd web/studio +npm install +npm run dev +``` + +#### Experience & LMS +```bash +# Iniciar backend LMS +cd services/lms-service +cargo run + +# Iniciar frontend Experience +cd web/experience +npm install +npm run dev +``` + +## 🔌 Manual del Desarrollador (API) + +### 1. Autenticación y Cuentas +Gestión de registro, login y perfiles organizacionales. + +#### POST /auth/register +Crea una nueva organización y el usuario administrador inicial. + +- **Lógica Inteligente**: Si `organization_name` está vacío, se utiliza el dominio del email. El primer usuario es marcado como `role: admin`. +- **Cuerpo de la Petición ( AuthPayload ):** ```json { "email": "string", - "password": "string" + "password": "string", + "full_name": "string", + "organization_name": "string", + "role": "string (admin | instructor | student)" + } + ``` +- **Respuesta Exitosa (200 OK) ( AuthResponse ):** + ```json + { + "token": "string (JWT)", + "user": { + "id": "uuid", + "email": "string", + "full_name": "string", + "role": "string", + "organization_id": "uuid" + } } ``` -### 📚 Gestión de Cursos +```bash +# Registrar un nuevo administrador y empresa +curl -X POST "http://localhost:3001/auth/register" \ + -H "Content-Type: application/json" \ + -d '{"email": "admin@empresa.com", "password": "pass", "organization_name": "OpenCCB Corp"}' +``` -#### Listar Cursos -- **URL**: `GET /courses` -- **Descripción**: Obtiene la lista de cursos visibles para el usuario. +--- -#### Crear Curso -- **URL**: `POST /courses` -- **Descripción**: Inicializa un nuevo curso. -- **Body (JSON)**: +### 2. Gestión de Contenidos (CMS) +Herramientas para instructores y administradores. + +#### POST /courses +Crea un nuevo curso vinculado a la organización del usuario. + +- **Lógica**: El `instructor_id` se asigna automáticamente desde el token JWT. +- **Cuerpo ( CreateCourseRequest ):** ```json { "title": "string", - "description": "string (optional)", - "passing_percentage": "integer (0-100, default: 70)" + "pacing_mode": "string (self_paced | instructor_led)" } ``` -#### Actualizar Curso -- **URL**: `PUT /courses/{id}` -- **Descripción**: Modifica metadatos del curso. -- **Body (JSON)**: - ```json - { - "title": "string", - "description": "string", - "passing_percentage": "integer", - "certificate_template": "string (HTML content)" - } - ``` +```bash +# Crear curso básico +curl -X POST "http://localhost:3001/courses" \ + -H "Authorization: Bearer $TOKEN" \ + -d '{"title": "Curso de Rust", "pacing_mode": "self_paced"}' +``` -#### Publicar Curso -- **URL**: `POST /courses/{id}/publish` -- **Descripción**: Sincroniza el curso y su contenido con el servicio LMS. -- **Body**: `{}` (Vacío) +#### POST /lessons +Agrega contenido multimedia o evaluaciones a un módulo. -### 📦 Contenido (Módulos y Lecciones) - -#### Crear Módulo -- **URL**: `POST /modules` -- **Body (JSON)**: - ```json - { - "course_id": "uuid", - "title": "string", - "order_index": "integer" - } - ``` - -#### Crear Lección -- **URL**: `POST /lessons` -- **Body (JSON)**: +- **Configuración Graduable**: Si `is_graded` es true, los puntos sumarán al XP del estudiante en el LMS. +- **Cuerpo ( CreateLessonRequest ):** ```json { "module_id": "uuid", "title": "string", - "content_type": "string ('video' | 'article' | 'quiz')", - "max_attempts": "integer (optional, null = unlimited)", - "allow_retry": "boolean (default: true)" + "content_type": "string (video | reading | quiz)", + "content_url": "string (opcional)", + "is_graded": "boolean", + "grading_category_id": "uuid (opcional)" } ``` -#### Actualizar Lección -- **URL**: `PUT /lessons/{id}` -- **Body (JSON)**: +```bash +# Agregar lección de video +curl -X POST "http://localhost:3001/lessons" \ + -H "Authorization: Bearer $TOKEN" \ + -d '{"module_id": "...", "title": "Intro", "content_type": "video", "is_graded": false}' +``` + +--- + +### 3. Experiencia de Aprendizaje (LMS) +Endpoints para estudiantes y seguimiento de progreso. + +#### POST /enroll +Inscribe al usuario en un curso. + +- **Lógica**: Verifica que el curso pertenezca a la misma organización que el usuario. +- **Cuerpo ( EnrollPayload ):** ```json { - "title": "string", - "content_blocks": "array (JSON objects)", - "max_attempts": "integer", - "allow_retry": "boolean" + "course_id": "uuid" } ``` -#### Transcripción AI (Simulado) -- **URL**: `POST /lessons/{id}/transcribe` -- **Descripción**: Inicia el proceso de generación de subtítulos/resumen. -- **Body**: `{}` (Vacío) +```bash +# Inscribirse en un curso +curl -X POST "http://localhost:3002/enroll" \ + -H "Authorization: Bearer $TOKEN" \ + -d '{"course_id": "uuid-del-curso"}' +``` -### 📂 Sistema & Assets +#### POST /grades +Registra el puntaje de una lección y actualiza la gamificación. -#### Subir Archivo -- **URL**: `POST /assets/upload` -- **Tipo**: `multipart/form-data` -- **Campo**: `file` (Binary) - -#### Logs de Auditoría -- **URL**: `GET /audit-logs` -- **Query Params**: `?page=1&limit=50` - -### 🏢 Organizaciones & Branding - -#### Listar Organizaciones (Admin) -- **URL**: `GET /organizations` -- **Descripción**: Obtiene la lista completa de inquilinos del sistema. - -#### Configurar Branding -- **URL**: `PUT /organizations/{id}/branding` -- **Descripción**: Actualiza los colores primario y secundario de la organización. -- **Body (JSON)**: +- **Lógica Inteligente**: Actualiza automáticamente el XP del usuario y despacha webhooks si el curso se completa. +- **Cuerpo ( GradeSubmissionPayload ):** ```json { - "primary_color": "#hex", - "secondary_color": "#hex" + "course_id": "uuid", + "lesson_id": "uuid", + "score": "float (0.0 a 1.0)", + "metadata": "object (opcional)" } ``` -#### Subir Logo de Organización -- **URL**: `POST /organizations/{id}/logo` -- **Tipo**: `multipart/form-data` -- **Campo**: `file` (Binary) +```bash +# Enviar calificación de 90% +curl -X POST "http://localhost:3002/grades" \ + -H "Authorization: Bearer $TOKEN" \ + -d '{"course_id": "...", "lesson_id": "...", "score": 0.9}' +``` -#### Obtener Branding Público -- **URL**: `GET /organizations/{id}/branding` -- **Descripción**: Recupera la identidad visual (logo y colores) de una organización. +--- -### 👥 Gestión de Usuarios (Admin) +### 4. IA y Analíticas Avanzadas +Funcionalidades inteligentes y métricas de negocio. -#### Listar Usuarios -- **URL**: `GET /users` -- **Descripción**: Obtiene todos los usuarios registrados en el sistema. +#### POST /chat (Streaming) +Conversación en tiempo real con la base de conocimientos. -#### Actualizar Usuario -- **URL**: `PUT /users/{id}` -- **Descripción**: Permite cambiar el rol o la organización de un usuario. -- **Body (JSON)**: +- **Nueva Sesión**: Omite `session_id`. La API creará uno nuevo y generará un título automático. +- **Continuar Sesión**: Envía el `session_id` devuelto anteriormente. +- **RAG (Base de Conocimiento)**: Envía `"use_kb": true` para que la IA busque en los documentos de S3. +- **Cuerpo ( ChatPayload ):** ```json { - "role": "string", - "organization_id": "uuid" + "username": "string", + "prompt": "string", + "session_id": "uuid (opcional)", + "use_kb": "boolean" } ``` -## 📦 Configuración y Ejecución +```bash +# Iniciar chat con RAG +curl -X POST "http://localhost:8000/chat" \ + -H "Content-Type: application/json" \ + -d '{ + "username": "juan", + "prompt": "Explícame qué es Docker en una frase", + "use_kb": true + }' +``` +**Respuesta**: Stream de texto plano. Al final incluye un JSON con el ID de sesión: `{"session_id": "..."}`. -1. **Variables de Entorno**: - Asegúrate de tener configurado `DATABASE_URL` en tu archivo `.env`. +#### GET /courses/{id}/analytics/advanced +Métricas de retención y análisis de cohortes. -2. **Base de Datos**: - El sistema utiliza migraciones automáticas de `sqlx` al iniciar. +- **Respuesta ( AdvancedAnalyticsResponse ):** + ```json + { + "cohorts": [ + { "period": "string", "count": "int", "completion_rate": "float" } + ], + "retention": [ + { "lesson_id": "uuid", "lesson_title": "string", "student_count": "int" } + ] + } + ``` -3. **Ejecutar Servicio**: - ```bash - cargo run --bin cms-service - ``` - O mediante Docker: - ```bash - docker-compose up --build - ``` \ No newline at end of file +--- + +## 🏆 Gamificación y Analíticas +OpenCCB incluye un sistema integrado de: +- **XP y Niveles**: Los estudiantes progresan al completar lecciones. +- **Leaderboards**: Rankings dentro de la organización. +- **Analíticas Avanzadas**: Análisis de cohortes y mapas de calor de retención para instructores. + +## 📄 Licencia +Este proyecto es código abierto y está disponible bajo los términos de la licencia especificada en el repositorio. \ No newline at end of file diff --git a/diagnose_auth.sh b/diagnose_auth.sh index f222ce9..6a23e6c 100755 --- a/diagnose_auth.sh +++ b/diagnose_auth.sh @@ -35,7 +35,7 @@ fi echo "" echo "3. Verificando JWT_SECRET en el contenedor..." -JWT_SECRET=$(docker exec openccb-1-cms-service-1 env | grep JWT_SECRET | cut -d'=' -f2) +JWT_SECRET=$(docker exec openccb-studio-1 env | grep JWT_SECRET | cut -d'=' -f2) echo "JWT_SECRET actual: $JWT_SECRET" echo "" diff --git a/docker-compose.yml b/docker-compose.yml index 10304fa..7b05d1d 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -10,52 +10,38 @@ services: volumes: - postgres_data:/var/lib/postgresql/data - cms-service: + studio: build: context: . - dockerfile: services/cms-service/Dockerfile + dockerfile: web/studio/Dockerfile + ports: + - "3000:3000" + - "3001:3001" environment: DATABASE_URL: postgresql://user:password@db:5432/openccb_cms JWT_SECRET: openccb_secret_key_2025_production - ports: - - "3001:3001" + NEXT_PUBLIC_CMS_API_URL: http://localhost:3001 volumes: - uploads_data:/app/uploads env_file: .env depends_on: - db - lms-service: + experience: build: context: . - dockerfile: services/lms-service/Dockerfile + dockerfile: web/experience/Dockerfile + ports: + - "3003:3003" + - "3002:3002" environment: DATABASE_URL: postgresql://user:password@db:5432/openccb_lms JWT_SECRET: openccb_secret_key_2025_production - ports: - - "3002:3002" + NEXT_PUBLIC_LMS_API_URL: http://localhost:3002 env_file: .env depends_on: - db - studio: - build: - context: ./web/studio - dockerfile: Dockerfile - ports: - - "3000:3000" - environment: - NEXT_PUBLIC_CMS_API_URL: http://localhost:3001 - - experience: - build: - context: ./web/experience - dockerfile: Dockerfile - ports: - - "3003:3003" - environment: - NEXT_PUBLIC_LMS_API_URL: http://localhost:3002 - whisper: image: ${WHISPER_IMAGE:-fedirz/faster-whisper-server:latest-cpu} ports: diff --git a/install.sh b/install.sh index bd48f45..ae702ee 100755 --- a/install.sh +++ b/install.sh @@ -175,8 +175,7 @@ DATABASE_URL=$LMS_URL sqlx migrate run --source services/lms-service/migrations echo "" echo "👤 Creating Initial Administrator..." API_URL="http://localhost:3001" -# Start the CMS service temporarily to create the user? -# Better yet, start all services with docker compose +# Start the Studio service (which contains CMS) to allow admin creation echo "🚀 Starting services to allow admin creation..." docker compose up -d --build echo "⏳ Waiting for CMS API to be ready..." @@ -209,6 +208,6 @@ echo "" echo "====================================================" echo " ✨ OpenCCB Installation Complete!" echo "====================================================" -echo "Studio: http://localhost:3000" -echo "Experience: http://localhost:3003" +echo "Studio (Admin/CMS): http://localhost:3000" +echo "Experience (LMS): http://localhost:3003" echo "====================================================" diff --git a/roadmap.md b/roadmap.md index 6015e21..9dfbd59 100644 --- a/roadmap.md +++ b/roadmap.md @@ -155,13 +155,12 @@ **Platform Maturity**: Core functionality is production-ready. Advanced features like AI integration are under active development. **Recent Milestones**: +- ✅ **Gamification System**: XP, Levels, and Leaderboards integrated into the student experience. - ✅ **Organization Branding**: Full customization of logos and brand colors across both portals. - ✅ **Multi-Tenancy**: Full support for multiple organizations, from the database to the frontend. - ✅ **Holistic Grading System**: Weighted categories, attempt tracking, and dynamic passing thresholds. -- ✅ **Analytics Dashboards**: Performance insights for both instructors and students. -- ✅ **Full Content Editor**: Robust activity builder with multiple interactive block types. **Next Priorities**: -1. **AI Integration**: Implement real-time video transcription. -2. **Gamification**: Expand the badges and achievement system. -3. **Advanced Analytics**: Develop cohort analysis and retention metrics. +1. **AI Integration**: Implement real-time video transcription (external and local provider). +2. **Advanced Analytics**: Develop cohort analysis and retention metrics. +3. **Enterprise Ecosystem**: SSO (Single Sign-On) and Webhooks. diff --git a/services/cms-service/Cargo.toml b/services/cms-service/Cargo.toml index c83be6a..cad4877 100644 --- a/services/cms-service/Cargo.toml +++ b/services/cms-service/Cargo.toml @@ -20,3 +20,6 @@ tower-http.workspace = true reqwest.workspace = true bcrypt.workspace = true jsonwebtoken.workspace = true +hmac.workspace = true +sha2.workspace = true +hex.workspace = true diff --git a/services/cms-service/Dockerfile b/services/cms-service/Dockerfile deleted file mode 100644 index aead08d..0000000 --- a/services/cms-service/Dockerfile +++ /dev/null @@ -1,23 +0,0 @@ -# Build stage -FROM rustlang/rust:nightly AS builder - -WORKDIR /usr/src/app -COPY . . - -# Install necessary build dependencies -RUN apt-get update && apt-get install -y pkg-config libssl-dev && rm -rf /var/lib/apt/lists/* - -# Build the specific service -RUN cargo build --release -p cms-service - -# Final stage -FROM debian:bookworm-slim -RUN apt-get update && apt-get install -y libssl3 ca-certificates && rm -rf /var/lib/apt/lists/* - -WORKDIR /usr/local/bin -COPY --from=builder /usr/src/app/target/release/cms-service . - -ENV RUST_LOG=info -EXPOSE 3001 - -CMD ["./cms-service"] diff --git a/services/cms-service/migrations/20260111000000_gamification_users.sql b/services/cms-service/migrations/20260111000000_gamification_users.sql new file mode 100644 index 0000000..c11bc26 --- /dev/null +++ b/services/cms-service/migrations/20260111000000_gamification_users.sql @@ -0,0 +1,4 @@ +-- Add XP and Level to users for gamification +ALTER TABLE users +ADD COLUMN xp INTEGER NOT NULL DEFAULT 0, +ADD COLUMN level INTEGER NOT NULL DEFAULT 1; diff --git a/services/cms-service/migrations/20260111000001_webhooks.sql b/services/cms-service/migrations/20260111000001_webhooks.sql new file mode 100644 index 0000000..1f91f2f --- /dev/null +++ b/services/cms-service/migrations/20260111000001_webhooks.sql @@ -0,0 +1,14 @@ +-- Migration: Create Webhooks Table +CREATE TABLE webhooks ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + organization_id UUID NOT NULL REFERENCES organizations(id) ON DELETE CASCADE, + url VARCHAR(500) NOT NULL, + events VARCHAR(50)[] NOT NULL, -- e.g., ['course.published', 'lesson.completed'] + secret VARCHAR(255), -- For HMAC-SHA256 signatures + is_active BOOLEAN NOT NULL DEFAULT TRUE, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- Index for organization_id +CREATE INDEX idx_webhooks_organization_id ON webhooks(organization_id); diff --git a/services/cms-service/migrations/20260111000002_advanced_auditing.sql b/services/cms-service/migrations/20260111000002_advanced_auditing.sql new file mode 100644 index 0000000..3b57429 --- /dev/null +++ b/services/cms-service/migrations/20260111000002_advanced_auditing.sql @@ -0,0 +1,130 @@ +-- Migration: Advanced Auditing and Automatic Triggers +-- Upgrade audit_logs table and implement automated change tracking + +-- 1. Upgrade audit_logs table +ALTER TABLE audit_logs +ADD COLUMN IF NOT EXISTS event_type VARCHAR(50) DEFAULT 'USER_EVENT', +ADD COLUMN IF NOT EXISTS old_data JSONB, +ADD COLUMN IF NOT EXISTS new_data JSONB, +ADD COLUMN IF NOT EXISTS ip_address INET, +ADD COLUMN IF NOT EXISTS public_ip INET, +ADD COLUMN IF NOT EXISTS user_agent TEXT, +ADD COLUMN IF NOT EXISTS metadata JSONB DEFAULT '{}'; + +-- 2. Create Audit Trigger Function +CREATE OR REPLACE FUNCTION fn_trigger_audit_log() +RETURNS TRIGGER AS $$ +DECLARE + v_user_id UUID; + v_org_id UUID; + v_ip INET; + v_user_agent TEXT; + v_event_type VARCHAR(50); + v_old_data JSONB := NULL; + v_new_data JSONB := NULL; + v_action VARCHAR(50); +BEGIN + -- Try to get context from session variables + BEGIN + v_user_id := current_setting('app.current_user_id', true)::UUID; + EXCEPTION WHEN OTHERS THEN + v_user_id := NULL; + END; + + BEGIN + v_org_id := current_setting('app.current_org_id', true)::UUID; + EXCEPTION WHEN OTHERS THEN + v_org_id := NULL; + END; + + BEGIN + v_ip := current_setting('app.client_ip', true)::INET; + EXCEPTION WHEN OTHERS THEN + v_ip := NULL; + END; + + BEGIN + v_user_agent := current_setting('app.user_agent', true); + EXCEPTION WHEN OTHERS THEN + v_user_agent := NULL; + END; + + BEGIN + v_event_type := current_setting('app.event_type', true); + EXCEPTION WHEN OTHERS THEN + v_event_type := 'USER_EVENT'; + END; + + -- Handle different operations + IF (TG_OP = 'DELETE') THEN + v_old_data := to_jsonb(OLD); + v_action := 'DELETE'; + ELSIF (TG_OP = 'UPDATE') THEN + v_old_data := to_jsonb(OLD); + v_new_data := to_jsonb(NEW); + v_action := 'UPDATE'; + ELSIF (TG_OP = 'INSERT') THEN + v_new_data := to_jsonb(NEW); + v_action := 'INSERT'; + END IF; + + -- Insert into audit_logs + INSERT INTO audit_logs ( + organization_id, + user_id, + action, + entity_type, + entity_id, + event_type, + old_data, + new_data, + ip_address, + user_agent + ) + VALUES ( + COALESCE(v_org_id, (CASE WHEN TG_OP = 'DELETE' THEN OLD.organization_id ELSE NEW.organization_id END)), + v_user_id, + v_action, + TG_TABLE_NAME, + CASE WHEN TG_OP = 'DELETE' THEN OLD.id ELSE NEW.id END, + COALESCE(v_event_type, 'USER_EVENT'), + v_old_data, + v_new_data, + v_ip, + v_user_agent + ); + + IF (TG_OP = 'DELETE') THEN + RETURN OLD; + END IF; + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +-- 3. Attach triggers to core tables +DO $$ +BEGIN + -- Courses + IF EXISTS (SELECT 1 FROM pg_tables WHERE tablename = 'courses') THEN + DROP TRIGGER IF EXISTS trg_audit_courses ON courses; + CREATE TRIGGER trg_audit_courses + AFTER INSERT OR UPDATE OR DELETE ON courses + FOR EACH ROW EXECUTE FUNCTION fn_trigger_audit_log(); + END IF; + + -- Lessons + IF EXISTS (SELECT 1 FROM pg_tables WHERE tablename = 'lessons') THEN + DROP TRIGGER IF EXISTS trg_audit_lessons ON lessons; + CREATE TRIGGER trg_audit_lessons + AFTER INSERT OR UPDATE OR DELETE ON lessons + FOR EACH ROW EXECUTE FUNCTION fn_trigger_audit_log(); + END IF; + + -- Users + IF EXISTS (SELECT 1 FROM pg_tables WHERE tablename = 'users') THEN + DROP TRIGGER IF EXISTS trg_audit_users ON users; + CREATE TRIGGER trg_audit_users + AFTER INSERT OR UPDATE OR DELETE ON users + FOR EACH ROW EXECUTE FUNCTION fn_trigger_audit_log(); + END IF; +END $$; diff --git a/services/cms-service/migrations/20260111000005_crud_functions.sql b/services/cms-service/migrations/20260111000005_crud_functions.sql new file mode 100644 index 0000000..3b6caa2 --- /dev/null +++ b/services/cms-service/migrations/20260111000005_crud_functions.sql @@ -0,0 +1,239 @@ +-- Migration: CMS CRUD Functions +-- Encapsulate all data mutations in stored functions + +-- 1. Course Management +CREATE OR REPLACE FUNCTION fn_create_course( + p_organization_id UUID, + p_instructor_id UUID, + p_title VARCHAR(255), + p_pacing_mode VARCHAR(50) DEFAULT 'self_paced' +) RETURNS SETOF courses AS $$ +BEGIN + RETURN QUERY + INSERT INTO courses (organization_id, instructor_id, title, pacing_mode) + VALUES (p_organization_id, p_instructor_id, p_title, p_pacing_mode) + RETURNING *; +END; +$$ LANGUAGE plpgsql; + +CREATE OR REPLACE FUNCTION fn_update_course( + p_id UUID, + p_organization_id UUID, + p_title VARCHAR(255), + p_description TEXT, + p_passing_percentage INTEGER, + p_pacing_mode VARCHAR(50), + p_start_date TIMESTAMPTZ, + p_end_date TIMESTAMPTZ, + p_certificate_template VARCHAR(255) DEFAULT NULL +) RETURNS SETOF courses AS $$ +BEGIN + RETURN QUERY + UPDATE courses + SET title = p_title, + description = p_description, + passing_percentage = p_passing_percentage, + pacing_mode = p_pacing_mode, + start_date = p_start_date, + end_date = p_end_date, + certificate_template = p_certificate_template, + updated_at = NOW() + WHERE id = p_id AND organization_id = p_organization_id + RETURNING *; +END; +$$ LANGUAGE plpgsql; + +CREATE OR REPLACE FUNCTION fn_delete_course( + p_id UUID, + p_organization_id UUID +) RETURNS BOOLEAN AS $$ +DECLARE + v_deleted_count INTEGER; +BEGIN + DELETE FROM courses + WHERE id = p_id AND organization_id = p_organization_id; + + GET DIAGNOSTICS v_deleted_count = ROW_COUNT; + RETURN v_deleted_count > 0; +END; +$$ LANGUAGE plpgsql; + +-- 2. Module Management +CREATE OR REPLACE FUNCTION fn_create_module( + p_organization_id UUID, + p_course_id UUID, + p_title VARCHAR(255), + p_position INTEGER +) RETURNS SETOF modules AS $$ +BEGIN + RETURN QUERY + INSERT INTO modules (organization_id, course_id, title, position) + VALUES (p_organization_id, p_course_id, p_title, p_position) + RETURNING *; +END; +$$ LANGUAGE plpgsql; + +CREATE OR REPLACE FUNCTION fn_update_module( + p_id UUID, + p_organization_id UUID, + p_title VARCHAR(255) DEFAULT NULL, + p_position INTEGER DEFAULT NULL +) RETURNS SETOF modules AS $$ +BEGIN + RETURN QUERY + UPDATE modules + SET title = COALESCE(p_title, title), + position = COALESCE(p_position, position), + updated_at = NOW() + WHERE id = p_id AND organization_id = p_organization_id + RETURNING *; +END; +$$ LANGUAGE plpgsql; + +-- 3. Lesson Management +CREATE OR REPLACE FUNCTION fn_create_lesson( + p_organization_id UUID, + p_module_id UUID, + p_title VARCHAR(255), + p_content_type VARCHAR(50), + p_content_url VARCHAR(500) DEFAULT NULL, + p_position INTEGER DEFAULT 0, + p_transcription JSONB DEFAULT NULL, + p_metadata JSONB DEFAULT NULL, + p_is_graded BOOLEAN DEFAULT FALSE, + p_grading_category_id UUID DEFAULT NULL, + p_max_attempts INTEGER DEFAULT NULL, + p_allow_retry BOOLEAN DEFAULT TRUE, + p_due_date TIMESTAMPTZ DEFAULT NULL, + p_important_date_type VARCHAR(50) DEFAULT NULL +) RETURNS SETOF lessons AS $$ +BEGIN + RETURN QUERY + INSERT INTO lessons ( + organization_id, module_id, title, content_type, content_url, + position, transcription, metadata, is_graded, grading_category_id, + max_attempts, allow_retry, due_date, important_date_type + ) + VALUES ( + p_organization_id, p_module_id, p_title, p_content_type, p_content_url, + p_position, p_transcription, p_metadata, p_is_graded, p_grading_category_id, + p_max_attempts, p_allow_retry, p_due_date, p_important_date_type + ) + RETURNING *; +END; +$$ LANGUAGE plpgsql; + +CREATE OR REPLACE FUNCTION fn_update_lesson( + p_id UUID, + p_organization_id UUID, + p_title VARCHAR(255) DEFAULT NULL, + p_content_type VARCHAR(50) DEFAULT NULL, + p_content_url VARCHAR(500) DEFAULT NULL, + p_content_blocks JSONB DEFAULT NULL, + p_transcription JSONB DEFAULT NULL, + p_metadata JSONB DEFAULT NULL, + p_is_graded BOOLEAN DEFAULT NULL, + p_grading_category_id UUID DEFAULT NULL, + p_max_attempts INTEGER DEFAULT NULL, + p_allow_retry BOOLEAN DEFAULT NULL, + p_position INTEGER DEFAULT NULL, + p_due_date TIMESTAMPTZ DEFAULT NULL, + p_important_date_type VARCHAR(50) DEFAULT NULL, + p_summary TEXT DEFAULT NULL, + p_clear_due_date BOOLEAN DEFAULT FALSE, + p_clear_grading_category BOOLEAN DEFAULT FALSE +) RETURNS SETOF lessons AS $$ +BEGIN + RETURN QUERY + UPDATE lessons + SET title = COALESCE(p_title, title), + content_type = COALESCE(p_content_type, content_type), + content_url = COALESCE(p_content_url, content_url), + content_blocks = COALESCE(p_content_blocks, content_blocks), + transcription = COALESCE(p_transcription, transcription), + metadata = COALESCE(p_metadata, metadata), + is_graded = COALESCE(p_is_graded, is_graded), + grading_category_id = CASE + WHEN p_clear_grading_category THEN NULL + ELSE COALESCE(p_grading_category_id, grading_category_id) + END, + max_attempts = COALESCE(p_max_attempts, max_attempts), + allow_retry = COALESCE(p_allow_retry, allow_retry), + position = COALESCE(p_position, position), + due_date = CASE + WHEN p_clear_due_date THEN NULL + ELSE COALESCE(p_due_date, due_date) + END, + important_date_type = COALESCE(p_important_date_type, important_date_type), + summary = COALESCE(p_summary, summary), + updated_at = NOW() + WHERE id = p_id AND organization_id = p_organization_id + RETURNING *; +END; +$$ LANGUAGE plpgsql; + +-- 4. Content Reordering +CREATE OR REPLACE PROCEDURE pr_reorder_modules( + p_organization_id UUID, + p_updates JSONB -- Array of {id, position} +) AS $$ +DECLARE + v_update JSONB; +BEGIN + FOR v_update IN SELECT * FROM jsonb_array_elements(p_updates) + LOOP + UPDATE modules + SET position = (v_update->>'position')::INTEGER + WHERE id = (v_update->>'id')::UUID AND organization_id = p_organization_id; + END LOOP; +END; +$$ LANGUAGE plpgsql; + +CREATE OR REPLACE PROCEDURE pr_reorder_lessons( + p_organization_id UUID, + p_updates JSONB -- Array of {id, position} +) AS $$ +DECLARE + v_update JSONB; +BEGIN + FOR v_update IN SELECT * FROM jsonb_array_elements(p_updates) + LOOP + UPDATE lessons + SET position = (v_update->>'position')::INTEGER + WHERE id = (v_update->>'id')::UUID AND organization_id = p_organization_id; + END LOOP; +END; +$$ LANGUAGE plpgsql; + +-- 5. User & Auth Management +CREATE OR REPLACE FUNCTION fn_register_user( + p_email VARCHAR(255), + p_password_hash VARCHAR(255), + p_full_name VARCHAR(255), + p_role VARCHAR(50), + p_org_name VARCHAR(255) +) RETURNS SETOF users AS $$ +DECLARE + v_org_id UUID; +BEGIN + -- Find or create organization + INSERT INTO organizations (name) + VALUES (p_org_name) + ON CONFLICT (name) DO UPDATE SET name = EXCLUDED.name + RETURNING id INTO v_org_id; + + -- Create user + RETURN QUERY + INSERT INTO users (email, password_hash, full_name, role, organization_id) + VALUES (p_email, p_password_hash, p_full_name, p_role, v_org_id) + RETURNING *; +END; +$$ LANGUAGE plpgsql; + +CREATE OR REPLACE FUNCTION fn_get_user_by_email( + p_email VARCHAR(255) +) RETURNS SETOF users AS $$ +BEGIN + RETURN QUERY SELECT * FROM users WHERE email = p_email; +END; +$$ LANGUAGE plpgsql; diff --git a/services/cms-service/src/db_util.rs b/services/cms-service/src/db_util.rs new file mode 100644 index 0000000..5fa99c6 --- /dev/null +++ b/services/cms-service/src/db_util.rs @@ -0,0 +1,43 @@ +use sqlx::{Postgres, Transaction}; +use uuid::Uuid; + +pub async fn set_session_context( + tx: &mut Transaction<'_, Postgres>, + user_id: Option, + org_id: Option, + ip_address: Option, + user_agent: Option, + event_type: Option, +) -> Result<(), sqlx::Error> { + if let Some(uid) = user_id { + let _ = sqlx::query("SELECT set_config('app.current_user_id', $1, true)") + .bind(uid.to_string()) + .execute(&mut **tx) + .await?; + } + if let Some(oid) = org_id { + let _ = sqlx::query("SELECT set_config('app.current_org_id', $1, true)") + .bind(oid.to_string()) + .execute(&mut **tx) + .await?; + } + if let Some(ip) = ip_address { + let _ = sqlx::query("SELECT set_config('app.client_ip', $1, true)") + .bind(ip) + .execute(&mut **tx) + .await?; + } + if let Some(ua) = user_agent { + let _ = sqlx::query("SELECT set_config('app.user_agent', $1, true)") + .bind(ua) + .execute(&mut **tx) + .await?; + } + if let Some(et) = event_type { + let _ = sqlx::query("SELECT set_config('app.event_type', $1, true)") + .bind(et) + .execute(&mut **tx) + .await?; + } + Ok(()) +} diff --git a/services/cms-service/src/handlers.rs b/services/cms-service/src/handlers.rs index b8096e1..19a27e5 100644 --- a/services/cms-service/src/handlers.rs +++ b/services/cms-service/src/handlers.rs @@ -1,3 +1,4 @@ +use crate::webhooks::WebhookService; use axum::{ Json, extract::{Path, Query, State}, @@ -89,6 +90,21 @@ pub async fn publish_course( log_action(&pool, Uuid::new_v4(), "PUBLISH", "Course", id, json!({})).await; + // 5. Trigger Webhook + let webhook_service = WebhookService::new(pool.clone()); + webhook_service + .dispatch( + org_ctx.id, + "course.published", + &json!({ + "course_id": id, + "title": payload.course.title, + "pacing_mode": payload.course.pacing_mode, + "published_at": Utc::now() + }), + ) + .await; + Ok(StatusCode::OK) } @@ -114,6 +130,7 @@ pub async fn create_course( Org(org_ctx): Org, claims: common::auth::Claims, State(pool): State, + headers: axum::http::HeaderMap, Json(payload): Json, ) -> Result, StatusCode> { let title = payload @@ -127,29 +144,53 @@ pub async fn create_course( .and_then(|v| v.as_str()) .unwrap_or("self_paced"); - let course = sqlx::query_as::<_, Course>( - "INSERT INTO courses (title, instructor_id, organization_id, pacing_mode) VALUES ($1, $2, $3, $4) RETURNING *" - ) - .bind(title) - .bind(instructor_id) - .bind(org_ctx.id) - .bind(pacing_mode) - .fetch_one(&pool) - .await - .map_err(|e| { - tracing::error!("Create course failed: {}", e); + let mut tx = pool.begin().await.map_err(|e| { + tracing::error!("Failed to start transaction: {}", e); StatusCode::INTERNAL_SERVER_ERROR })?; - log_action( - &pool, - instructor_id, - "CREATE", - "Course", - course.id, - json!({ "title": title, "pacing_mode": pacing_mode }), + // Set session context for the DB trigger to find + let ip = headers + .get("x-forwarded-for") + .and_then(|h| h.to_str().ok()) + .or_else(|| headers.get("x-real-ip").and_then(|h| h.to_str().ok())) + .map(|s| s.to_string()); + + let ua = headers + .get("user-agent") + .and_then(|h| h.to_str().ok()) + .map(|s| s.to_string()); + + crate::db_util::set_session_context( + &mut tx, + Some(instructor_id), + Some(org_ctx.id), + ip, + ua, + Some("USER_EVENT".to_string()), ) - .await; + .await + .map_err(|e| { + tracing::error!("Failed to set session context: {}", e); + StatusCode::INTERNAL_SERVER_ERROR + })?; + + let course = sqlx::query_as::<_, Course>("SELECT * FROM fn_create_course($1, $2, $3, $4)") + .bind(org_ctx.id) + .bind(instructor_id) + .bind(title) + .bind(pacing_mode) + .fetch_one(&mut *tx) + .await + .map_err(|e| { + tracing::error!("Create course failed: {}", e); + StatusCode::INTERNAL_SERVER_ERROR + })?; + + tx.commit().await.map_err(|e| { + tracing::error!("Failed to commit transaction: {}", e); + StatusCode::INTERNAL_SERVER_ERROR + })?; Ok(Json(course)) } @@ -222,27 +263,34 @@ pub async fn update_course( .or(existing.end_date); let course = sqlx::query_as::<_, Course>( - "UPDATE courses SET title = $1, description = $2, passing_percentage = $3, certificate_template = $4, pacing_mode = $5, start_date = $6, end_date = $7, updated_at = NOW() WHERE id = $8 AND organization_id = $9 RETURNING *" + "SELECT * FROM fn_update_course($1, $2, $3, $4, $5, $6, $7, $8, $9)", ) + .bind(id) + .bind(org_ctx.id) .bind(title) .bind(description) .bind(passing_percentage) - .bind(certificate_template) .bind(pacing_mode) .bind(start_date) .bind(end_date) - .bind(id) - .bind(org_ctx.id) + .bind(certificate_template) .fetch_one(&pool) .await - .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Failed to update course: {}", e)))?; + .map_err(|e| { + ( + StatusCode::INTERNAL_SERVER_ERROR, + format!("Failed to update course: {}", e), + ) + })?; Ok(Json(course)) } pub async fn create_module( + Org(org_ctx): Org, claims: common::auth::Claims, State(pool): State, + headers: axum::http::HeaderMap, Json(payload): Json, ) -> Result, StatusCode> { let title = payload @@ -259,32 +307,57 @@ pub async fn create_module( .and_then(|v| v.as_i64()) .unwrap_or(0) as i32; - let module = sqlx::query_as::<_, Module>( - "INSERT INTO modules (course_id, title, position) VALUES ($1, $2, $3) RETURNING *", + let mut tx = pool + .begin() + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + + let ip = headers + .get("x-forwarded-for") + .and_then(|h| h.to_str().ok()) + .or_else(|| headers.get("x-real-ip").and_then(|h| h.to_str().ok())) + .map(|s| s.to_string()); + + let ua = headers + .get("user-agent") + .and_then(|h| h.to_str().ok()) + .map(|s| s.to_string()); + + crate::db_util::set_session_context( + &mut tx, + Some(claims.sub), + Some(org_ctx.id), + ip, + ua, + Some("USER_EVENT".to_string()), ) - .bind(course_id) - .bind(title) - .bind(position) - .fetch_one(&pool) .await .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; - log_action( - &pool, - claims.sub, - "CREATE", - "Module", - module.id, - json!({ "title": title, "course_id": course_id }), - ) - .await; + let module = sqlx::query_as::<_, Module>("SELECT * FROM fn_create_module($1, $2, $3, $4)") + .bind(org_ctx.id) + .bind(course_id) + .bind(title) + .bind(position) + .fetch_one(&mut *tx) + .await + .map_err(|e| { + tracing::error!("Create module failed: {}", e); + StatusCode::INTERNAL_SERVER_ERROR + })?; + + tx.commit() + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; Ok(Json(module)) } pub async fn create_lesson( + Org(org_ctx): Org, claims: common::auth::Claims, State(pool): State, + headers: axum::http::HeaderMap, Json(payload): Json, ) -> Result, StatusCode> { let title = payload @@ -332,10 +405,37 @@ pub async fn create_lesson( let important_date_type = payload.get("important_date_type").and_then(|v| v.as_str()); - let lesson = sqlx::query_as::<_, Lesson>( - "INSERT INTO lessons (module_id, title, content_type, content_url, position, transcription, metadata, is_graded, grading_category_id, max_attempts, allow_retry, due_date, important_date_type) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13) RETURNING *" + let mut tx = pool + .begin() + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + + let ip = headers + .get("x-forwarded-for") + .and_then(|h| h.to_str().ok()) + .or_else(|| headers.get("x-real-ip").and_then(|h| h.to_str().ok())) + .map(|s| s.to_string()); + + let ua = headers + .get("user-agent") + .and_then(|h| h.to_str().ok()) + .map(|s| s.to_string()); + + crate::db_util::set_session_context( + &mut tx, + Some(claims.sub), + Some(org_ctx.id), + ip, + ua, + Some("USER_EVENT".to_string()), ) + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + + let lesson = sqlx::query_as::<_, Lesson>( + "SELECT * FROM fn_create_lesson($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14)" + ) + .bind(org_ctx.id) .bind(module_id) .bind(title) .bind(content_type) @@ -349,22 +449,16 @@ pub async fn create_lesson( .bind(allow_retry) .bind(due_date) .bind(important_date_type) - .fetch_one(&pool) + .fetch_one(&mut *tx) .await .map_err(|e| { tracing::error!("Create lesson failed: {}", e); StatusCode::INTERNAL_SERVER_ERROR })?; - log_action( - &pool, - claims.sub, - "CREATE", - "Lesson", - lesson.id, - json!({ "title": title, "module_id": module_id }), - ) - .await; + tx.commit() + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; Ok(Json(lesson)) } @@ -724,8 +818,10 @@ pub async fn get_lesson( } pub async fn update_lesson( + Org(org_ctx): Org, claims: common::auth::Claims, State(pool): State, + headers: axum::http::HeaderMap, Path(id): Path, Json(payload): Json, ) -> Result, StatusCode> { @@ -742,50 +838,90 @@ pub async fn update_lesson( .and_then(|v| v.as_i64()) .map(|v| v as i32); let allow_retry = payload.get("allow_retry").and_then(|v| v.as_bool()); - let metadata = payload.get("metadata"); + let metadata = payload.get("metadata").cloned(); let important_date_type = payload.get("important_date_type").and_then(|v| v.as_str()); + let summary = payload.get("summary").and_then(|v| v.as_str()); + let content_blocks = payload.get("content_blocks").cloned(); + let transcription = payload.get("transcription").cloned(); - let updated_lesson = sqlx::query_as::<_, Lesson>( - "UPDATE lessons - SET title = COALESCE($1, title), - content_type = COALESCE($2, content_type), - content_url = COALESCE($3, content_url), - position = COALESCE($4, position), - is_graded = COALESCE($5, is_graded), - grading_category_id = CASE WHEN $6 = 'SET_NULL' THEN NULL WHEN $7::UUID IS NOT NULL THEN $7 ELSE grading_category_id END, - metadata = COALESCE($8, metadata), - max_attempts = COALESCE($9, max_attempts), - allow_retry = COALESCE($10, allow_retry), - summary = COALESCE($11, summary), - due_date = CASE WHEN $12 = 'SET_NULL' THEN NULL WHEN $13::TIMESTAMPTZ IS NOT NULL THEN $13 ELSE due_date END, - important_date_type = COALESCE($14, important_date_type) - WHERE id = $15 RETURNING *" + let clear_grading_category = payload + .get("grading_category_id") + .map(|v| v.is_null()) + .unwrap_or(false); + let grading_category_id = payload + .get("grading_category_id") + .and_then(|v| v.as_str()) + .and_then(|s| Uuid::parse_str(s).ok()); + + let clear_due_date = payload + .get("due_date") + .map(|v| v.is_null()) + .unwrap_or(false); + let due_date = payload + .get("due_date") + .and_then(|v| v.as_str()) + .and_then(|s| s.parse::>().ok()); + + let mut tx = pool + .begin() + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + + let ip = headers + .get("x-forwarded-for") + .and_then(|h| h.to_str().ok()) + .or_else(|| headers.get("x-real-ip").and_then(|h| h.to_str().ok())) + .map(|s| s.to_string()); + + let ua = headers + .get("user-agent") + .and_then(|h| h.to_str().ok()) + .map(|s| s.to_string()); + + crate::db_util::set_session_context( + &mut tx, + Some(claims.sub), + Some(org_ctx.id), + ip, + ua, + Some("USER_EVENT".to_string()), ) + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + + let lesson = sqlx::query_as::<_, Lesson>( + "SELECT * FROM fn_update_lesson($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18)" + ) + .bind(id) + .bind(org_ctx.id) .bind(title) .bind(content_type) .bind(content_url) - .bind(position) - .bind(is_graded) - .bind(if payload.get("grading_category_id").map(|v| v.is_null()).unwrap_or(false) { "SET_NULL" } else { "" }) - .bind(payload.get("grading_category_id").and_then(|v| v.as_str()).and_then(|s| Uuid::parse_str(s).ok())) + .bind(content_blocks) + .bind(transcription) .bind(metadata) + .bind(is_graded) + .bind(grading_category_id) .bind(max_attempts) .bind(allow_retry) - .bind(payload.get("summary").and_then(|v| v.as_str())) - .bind(if payload.get("due_date").map(|v| v.is_null()).unwrap_or(false) { "SET_NULL" } else { "" }) - .bind(payload.get("due_date").and_then(|v| v.as_str()).and_then(|s| s.parse::>().ok())) + .bind(position) + .bind(due_date) .bind(important_date_type) - .bind(id) - .fetch_one(&pool) + .bind(summary) + .bind(clear_due_date) + .bind(clear_grading_category) + .fetch_one(&mut *tx) .await .map_err(|e| { tracing::error!("Update lesson failed: {}", e); StatusCode::INTERNAL_SERVER_ERROR })?; - log_action(&pool, claims.sub, "UPDATE", "Lesson", id, json!(payload)).await; + tx.commit() + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; - Ok(Json(updated_lesson)) + Ok(Json(lesson)) } // Grading Policies @@ -925,53 +1061,115 @@ pub async fn get_lessons( Ok(Json(lessons)) } -#[derive(Deserialize)] +#[derive(Deserialize, Serialize)] pub struct ReorderPayload { pub items: Vec, } -#[derive(Deserialize)] +#[derive(Deserialize, Serialize)] pub struct ReorderItem { pub id: Uuid, pub position: i32, } pub async fn reorder_modules( - _claims: common::auth::Claims, + Org(org_ctx): Org, + claims: common::auth::Claims, State(pool): State, + headers: axum::http::HeaderMap, Json(payload): Json, ) -> Result { - for item in payload.items { - sqlx::query("UPDATE modules SET position = $1 WHERE id = $2") - .bind(item.position) - .bind(item.id) - .execute(&pool) - .await - .map_err(|e| { - tracing::error!("Reorder modules failed: {}", e); - StatusCode::INTERNAL_SERVER_ERROR - })?; - } + let mut tx = pool + .begin() + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + + let ip = headers + .get("x-forwarded-for") + .and_then(|h| h.to_str().ok()) + .or_else(|| headers.get("x-real-ip").and_then(|h| h.to_str().ok())) + .map(|s| s.to_string()); + + let ua = headers + .get("user-agent") + .and_then(|h| h.to_str().ok()) + .map(|s| s.to_string()); + + crate::db_util::set_session_context( + &mut tx, + Some(claims.sub), + Some(org_ctx.id), + ip, + ua, + Some("USER_EVENT".to_string()), + ) + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + + sqlx::query("CALL pr_reorder_modules($1, $2)") + .bind(org_ctx.id) + .bind(serde_json::to_value(&payload.items).map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?) + .execute(&mut *tx) + .await + .map_err(|e| { + tracing::error!("Reorder modules failed: {}", e); + StatusCode::INTERNAL_SERVER_ERROR + })?; + + tx.commit() + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; Ok(StatusCode::OK) } pub async fn reorder_lessons( - _claims: common::auth::Claims, + Org(org_ctx): Org, + claims: common::auth::Claims, State(pool): State, + headers: axum::http::HeaderMap, Json(payload): Json, ) -> Result { - for item in payload.items { - sqlx::query("UPDATE lessons SET position = $1 WHERE id = $2") - .bind(item.position) - .bind(item.id) - .execute(&pool) - .await - .map_err(|e| { - tracing::error!("Reorder lessons failed: {}", e); - StatusCode::INTERNAL_SERVER_ERROR - })?; - } + let mut tx = pool + .begin() + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + + let ip = headers + .get("x-forwarded-for") + .and_then(|h| h.to_str().ok()) + .or_else(|| headers.get("x-real-ip").and_then(|h| h.to_str().ok())) + .map(|s| s.to_string()); + + let ua = headers + .get("user-agent") + .and_then(|h| h.to_str().ok()) + .map(|s| s.to_string()); + + crate::db_util::set_session_context( + &mut tx, + Some(claims.sub), + Some(org_ctx.id), + ip, + ua, + Some("USER_EVENT".to_string()), + ) + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + + sqlx::query("CALL pr_reorder_lessons($1, $2)") + .bind(org_ctx.id) + .bind(serde_json::to_value(&payload.items).map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?) + .execute(&mut *tx) + .await + .map_err(|e| { + tracing::error!("Reorder lessons failed: {}", e); + StatusCode::INTERNAL_SERVER_ERROR + })?; + + tx.commit() + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; Ok(StatusCode::OK) } @@ -1105,31 +1303,21 @@ pub async fn register( .await .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; - let organization = sqlx::query_as::<_, Organization>( - "INSERT INTO organizations (name) VALUES ($1) ON CONFLICT (name) DO UPDATE SET name = EXCLUDED.name RETURNING *" - ) - .bind(&org_name) - .fetch_one(&mut *tx) - .await - .map_err(|e| { - tracing::error!("Failed to create/find org: {}", e); - (StatusCode::INTERNAL_SERVER_ERROR, format!("Failed to find or create organization: {}", e)) - })?; - - let user = sqlx::query_as::<_, User>( - "INSERT INTO users (email, password_hash, full_name, role, organization_id) VALUES ($1, $2, $3, $4, $5) RETURNING *" - ) - .bind(&payload.email) - .bind(password_hash) - .bind(full_name) - .bind(&role) - .bind(organization.id) - .fetch_one(&mut *tx) - .await - .map_err(|e: sqlx::Error| { - tracing::error!("Failed to create user: {}", e); - (StatusCode::CONFLICT, format!("User already exists or DB error: {}", e)) - })?; + let user = sqlx::query_as::<_, User>("SELECT * FROM fn_register_user($1, $2, $3, $4, $5)") + .bind(&payload.email) + .bind(password_hash) + .bind(full_name) + .bind(&role) + .bind(&org_name) + .fetch_one(&mut *tx) + .await + .map_err(|e: sqlx::Error| { + tracing::error!("Failed to create user: {}", e); + ( + StatusCode::CONFLICT, + format!("User already exists or DB error: {}", e), + ) + })?; tx.commit() .await @@ -1160,7 +1348,7 @@ pub async fn login( State(pool): State, Json(payload): Json, ) -> Result, (StatusCode, String)> { - let user = sqlx::query_as::<_, User>("SELECT * FROM users WHERE email = $1") + let user = sqlx::query_as::<_, User>("SELECT * FROM fn_get_user_by_email($1)") .bind(&payload.email) .fetch_one(&pool) .await @@ -1241,6 +1429,55 @@ pub async fn get_course_analytics( Ok(Json(analytics)) } +pub async fn get_advanced_analytics( + Org(org_ctx): Org, + claims: common::auth::Claims, + State(pool): State, + Path(id): Path, +) -> Result, (StatusCode, String)> { + // 1. Fetch Course to check ownership + let course = + sqlx::query_as::<_, Course>("SELECT * FROM courses WHERE id = $1 AND organization_id = $2") + .bind(id) + .bind(org_ctx.id) + .fetch_one(&pool) + .await + .map_err(|_| (StatusCode::NOT_FOUND, "Course not found".into()))?; + + // 2. Enforce RBAC + if claims.role != "admin" && course.instructor_id != claims.sub { + return Err(( + StatusCode::FORBIDDEN, + "You do not have permission to view stats for a course you don't own".into(), + )); + } + + // 4. Fetch from LMS + let client = reqwest::Client::new(); + let res = client + .get(format!( + "http://lms-service:3002/courses/{}/analytics/advanced", + id + )) + .send() + .await + .map_err(|e| (StatusCode::BAD_GATEWAY, e.to_string()))?; + + if !res.status().is_success() { + return Err(( + StatusCode::INTERNAL_SERVER_ERROR, + "Failed to fetch advanced analytics from LMS".into(), + )); + } + + let analytics = res + .json::() + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + + Ok(Json(analytics)) +} + #[derive(Deserialize)] pub struct AuditQuery { pub page: Option, @@ -1356,8 +1593,10 @@ pub async fn get_course_outline( } pub async fn update_module( + Org(org_ctx): Org, claims: common::auth::Claims, State(pool): State, + headers: axum::http::HeaderMap, Path(id): Path, Json(payload): Json, ) -> Result, StatusCode> { @@ -1367,61 +1606,168 @@ pub async fn update_module( .and_then(|v| v.as_i64()) .map(|v| v as i32); - let updated_module = sqlx::query_as::<_, Module>( - "UPDATE modules - SET title = COALESCE($1, title), - position = COALESCE($2, position) - WHERE id = $3 RETURNING *", + let mut tx = pool + .begin() + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + + let ip = headers + .get("x-forwarded-for") + .and_then(|h| h.to_str().ok()) + .or_else(|| headers.get("x-real-ip").and_then(|h| h.to_str().ok())) + .map(|s| s.to_string()); + + let ua = headers + .get("user-agent") + .and_then(|h| h.to_str().ok()) + .map(|s| s.to_string()); + + crate::db_util::set_session_context( + &mut tx, + Some(claims.sub), + Some(org_ctx.id), + ip, + ua, + Some("USER_EVENT".to_string()), ) - .bind(title) - .bind(position) - .bind(id) - .fetch_one(&pool) .await - .map_err(|e| { - tracing::error!("Update module failed: {}", e); - StatusCode::INTERNAL_SERVER_ERROR - })?; + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; - log_action(&pool, claims.sub, "UPDATE", "Module", id, json!(payload)).await; + // Fetch existing to handle COALESCE if not handled in function + // For now, let's just use the function logic. + // Wait, the DB function I wrote doesn't use COALESCE, it just sets values. + // I should fix the DB function or handle COALESCE here. + // Let's fix the DB function to use COALESCE for optional updates. - Ok(Json(updated_module)) + let module = sqlx::query_as::<_, Module>("SELECT * FROM fn_update_module($1, $2, $3, $4)") + .bind(id) + .bind(org_ctx.id) + .bind(title) + .bind(position) + .fetch_one(&mut *tx) + .await + .map_err(|e| { + tracing::error!("Update module failed: {}", e); + StatusCode::INTERNAL_SERVER_ERROR + })?; + + tx.commit() + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + + Ok(Json(module)) } pub async fn delete_module( + Org(org_ctx): Org, claims: common::auth::Claims, State(pool): State, + headers: axum::http::HeaderMap, Path(id): Path, ) -> Result { - sqlx::query("DELETE FROM modules WHERE id = $1") + let mut tx = pool + .begin() + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + + let ip = headers + .get("x-forwarded-for") + .and_then(|h| h.to_str().ok()) + .or_else(|| headers.get("x-real-ip").and_then(|h| h.to_str().ok())) + .map(|s| s.to_string()); + + let ua = headers + .get("user-agent") + .and_then(|h| h.to_str().ok()) + .map(|s| s.to_string()); + + crate::db_util::set_session_context( + &mut tx, + Some(claims.sub), + Some(org_ctx.id), + ip, + ua, + Some("USER_EVENT".to_string()), + ) + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + + let success = sqlx::query_scalar::<_, bool>("SELECT fn_delete_module($1, $2)") .bind(id) - .execute(&pool) + .bind(org_ctx.id) + .fetch_one(&mut *tx) .await .map_err(|e| { tracing::error!("Delete module failed: {}", e); StatusCode::INTERNAL_SERVER_ERROR })?; - log_action(&pool, claims.sub, "DELETE", "Module", id, json!({})).await; + if !success { + return Err(StatusCode::NOT_FOUND); + } + + tx.commit() + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; Ok(StatusCode::OK) } pub async fn delete_lesson( + Org(org_ctx): Org, claims: common::auth::Claims, State(pool): State, + headers: axum::http::HeaderMap, Path(id): Path, ) -> Result { if claims.role != "admin" { return Err(StatusCode::FORBIDDEN); } - sqlx::query("DELETE FROM lessons WHERE id = $1") - .bind(id) - .execute(&pool) + + let mut tx = pool + .begin() .await .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; - log_action(&pool, claims.sub, "DELETE_LESSON", "Lesson", id, json!({})).await; + let ip = headers + .get("x-forwarded-for") + .and_then(|h| h.to_str().ok()) + .or_else(|| headers.get("x-real-ip").and_then(|h| h.to_str().ok())) + .map(|s| s.to_string()); + + let ua = headers + .get("user-agent") + .and_then(|h| h.to_str().ok()) + .map(|s| s.to_string()); + + crate::db_util::set_session_context( + &mut tx, + Some(claims.sub), + Some(org_ctx.id), + ip, + ua, + Some("USER_EVENT".to_string()), + ) + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + + let success = sqlx::query_scalar::<_, bool>("SELECT fn_delete_lesson($1, $2)") + .bind(id) + .bind(org_ctx.id) + .fetch_one(&mut *tx) + .await + .map_err(|e| { + tracing::error!("Delete lesson failed: {}", e); + StatusCode::INTERNAL_SERVER_ERROR + })?; + + if !success { + return Err(StatusCode::NOT_FOUND); + } + + tx.commit() + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; Ok(StatusCode::OK) } @@ -1529,3 +1875,102 @@ pub async fn create_organization( Ok(Json(org)) } + +#[derive(serde::Deserialize)] +pub struct CreateWebhookPayload { + pub url: String, + pub events: Vec, + pub secret: Option, +} + +pub async fn get_webhooks( + Org(org_ctx): Org, + claims: common::auth::Claims, + State(pool): State, +) -> Result>, (StatusCode, String)> { + if claims.role != "admin" { + return Err((StatusCode::FORBIDDEN, "Admin access required".into())); + } + + let webhooks = sqlx::query_as::<_, common::models::Webhook>( + "SELECT * FROM webhooks WHERE organization_id = ORDER BY created_at DESC", + ) + .bind(org_ctx.id) + .fetch_all(&pool) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + + Ok(Json(webhooks)) +} + +pub async fn create_webhook( + Org(org_ctx): Org, + claims: common::auth::Claims, + State(pool): State, + Json(payload): Json, +) -> Result, (StatusCode, String)> { + if claims.role != "admin" { + return Err((StatusCode::FORBIDDEN, "Admin access required".into())); + } + + let webhook = sqlx::query_as::<_, common::models::Webhook>( + r#" + INSERT INTO webhooks (organization_id, url, events, secret) + VALUES (, , , ) + RETURNING * + "#, + ) + .bind(org_ctx.id) + .bind(payload.url) + .bind(payload.events) + .bind(payload.secret) + .fetch_one(&pool) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + + log_action( + &pool, + claims.sub, + "CREATE_WEBHOOK", + "Webhook", + webhook.id, + serde_json::to_value(&webhook).unwrap(), + ) + .await; + + Ok(Json(webhook)) +} + +pub async fn delete_webhook( + Org(org_ctx): Org, + claims: common::auth::Claims, + State(pool): State, + Path(id): Path, +) -> Result { + if claims.role != "admin" { + return Err((StatusCode::FORBIDDEN, "Admin access required".into())); + } + + let result = sqlx::query("DELETE FROM webhooks WHERE id = AND organization_id = ") + .bind(id) + .bind(org_ctx.id) + .execute(&pool) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + + if result.rows_affected() == 0 { + return Err((StatusCode::NOT_FOUND, "Webhook not found".into())); + } + + log_action( + &pool, + claims.sub, + "DELETE_WEBHOOK", + "Webhook", + id, + serde_json::json!({}), + ) + .await; + + Ok(StatusCode::OK) +} diff --git a/services/cms-service/src/main.rs b/services/cms-service/src/main.rs index 509b9bb..461332a 100644 --- a/services/cms-service/src/main.rs +++ b/services/cms-service/src/main.rs @@ -1,5 +1,7 @@ +mod db_util; mod handlers; mod handlers_branding; +mod webhooks; use axum::{ Router, middleware, @@ -50,6 +52,10 @@ async fn main() { "/courses/{id}/analytics", get(handlers::get_course_analytics), ) + .route( + "/courses/{id}/analytics/advanced", + get(handlers::get_advanced_analytics), + ) .route( "/modules", get(handlers::get_modules).post(handlers::create_module), @@ -86,7 +92,15 @@ async fn main() { .route("/users/{id}", axum::routing::put(handlers::update_user)) .route("/audit-logs", get(handlers::get_audit_logs)) .route("/assets/upload", post(handlers::upload_asset)) - .route("/organizations", get(handlers::get_organizations).post(handlers::create_organization)) + .route( + "/organizations", + get(handlers::get_organizations).post(handlers::create_organization), + ) + .route( + "/webhooks", + get(handlers::get_webhooks).post(handlers::create_webhook), + ) + .route("/webhooks/{id}", delete(handlers::delete_webhook)) .route("/organization", get(handlers::get_organization)) .route( "/organizations/{id}/logo", diff --git a/services/cms-service/src/webhooks.rs b/services/cms-service/src/webhooks.rs new file mode 100644 index 0000000..8ff0b13 --- /dev/null +++ b/services/cms-service/src/webhooks.rs @@ -0,0 +1,93 @@ +use common::models::Webhook; +use hex; +use hmac::{Hmac, Mac}; +use sha2::Sha256; +use sqlx::PgPool; +use uuid::Uuid; + +pub struct WebhookService { + pool: PgPool, +} + +impl WebhookService { + pub fn new(pool: PgPool) -> Self { + Self { pool } + } + + pub async fn dispatch(&self, org_id: Uuid, event_type: &str, payload: &serde_json::Value) { + // 1. Fetch active webhooks for this org that are interested in this event + let webhooks = match sqlx::query_as::<_, Webhook>( + "SELECT * FROM webhooks WHERE organization_id = $1 AND is_active = TRUE AND $2 = ANY(events)" + ) + .bind(org_id) + .bind(event_type) + .fetch_all(&self.pool) + .await { + Ok(w) => w, + Err(e) => { + tracing::error!("Failed to fetch webhooks for org {}: {}", org_id, e); + return; + } + }; + + if webhooks.is_empty() { + return; + } + + let client = reqwest::Client::new(); + let payload_str = payload.to_string(); + + for webhook in webhooks { + let mut request = client + .post(&webhook.url) + .header("Content-Type", "application/json") + .header("X-OpenCCB-Event", event_type) + .header("X-OpenCCB-Delivery", Uuid::new_v4().to_string()); + + // Add signature if secret exists + if let Some(secret) = &webhook.secret { + let signature = generate_signature(secret, &payload_str); + request = request.header("X-OpenCCB-Signature", signature); + } + + let url = webhook.url.clone(); + let res = request.body(payload_str.clone()).send().await; + + match res { + Ok(response) => { + if !response.status().is_success() { + tracing::warn!( + "Webhook delivery to {} (event: {}) failed with status {}", + url, + event_type, + response.status() + ); + } else { + tracing::info!( + "Successfully delivered webhook to {} (event: {})", + url, + event_type + ); + } + } + Err(e) => { + tracing::error!( + "Failed to deliver webhook to {} (event: {}): {}", + url, + event_type, + e + ); + } + } + } + } +} + +fn generate_signature(secret: &str, payload: &str) -> String { + type HmacSha256 = Hmac; + let mut mac = + HmacSha256::new_from_slice(secret.as_bytes()).expect("HMAC can take key of any size"); + mac.update(payload.as_bytes()); + let result = mac.finalize(); + hex::encode(result.into_bytes()) +} diff --git a/services/lms-service/Dockerfile b/services/lms-service/Dockerfile deleted file mode 100644 index b5fde6f..0000000 --- a/services/lms-service/Dockerfile +++ /dev/null @@ -1,23 +0,0 @@ -# Build stage -FROM rustlang/rust:nightly AS builder - -WORKDIR /usr/src/app -COPY . . - -# Install necessary build dependencies -RUN apt-get update && apt-get install -y pkg-config libssl-dev && rm -rf /var/lib/apt/lists/* - -# Build the specific service -RUN cargo build --release -p lms-service - -# Final stage -FROM debian:bookworm-slim -RUN apt-get update && apt-get install -y libssl3 ca-certificates && rm -rf /var/lib/apt/lists/* - -WORKDIR /usr/local/bin -COPY --from=builder /usr/src/app/target/release/lms-service . - -ENV RUST_LOG=info -EXPOSE 3002 - -CMD ["./lms-service"] diff --git a/services/lms-service/migrations/20260111000002_lms_logic.sql b/services/lms-service/migrations/20260111000002_lms_logic.sql new file mode 100644 index 0000000..0f255e3 --- /dev/null +++ b/services/lms-service/migrations/20260111000002_lms_logic.sql @@ -0,0 +1,138 @@ +-- Migration: LMS Logic Functions (XP and Enrollment) + +-- 1. Function to award XP +CREATE OR REPLACE FUNCTION fn_award_xp( + p_user_id UUID, + p_org_id UUID, + p_amount INTEGER, + p_reason TEXT, + p_entity_type TEXT DEFAULT NULL, + p_entity_id UUID DEFAULT NULL +) RETURNS VOID AS $$ +BEGIN + -- Update XP in users table + UPDATE users + SET xp = xp + p_amount, + level = FLOOR(SQRT((xp + p_amount)::FLOAT / 100)) + 1, + updated_at = NOW() + WHERE id = p_user_id; + + -- Log to points_log + IF EXISTS (SELECT 1 FROM pg_tables WHERE tablename = 'points_log') THEN + INSERT INTO points_log (user_id, organization_id, amount, reason, entity_type, entity_id) + VALUES (p_user_id, p_org_id, p_amount, p_reason, p_entity_type, p_entity_id); + END IF; +END; +$$ LANGUAGE plpgsql; + +-- 2. Function for Course Cohort Analytics +CREATE OR REPLACE FUNCTION fn_get_cohort_analytics(p_course_id UUID) +RETURNS TABLE ( + period TEXT, + student_count BIGINT, + completion_rate FLOAT4 +) AS $$ +BEGIN + RETURN QUERY + WITH cohort_students AS ( + SELECT + user_id, + TO_CHAR(enrolled_at, 'YYYY-MM') as v_period + FROM enrollments + WHERE course_id = p_course_id + ), + course_lesson_count AS ( + SELECT COUNT(*)::float4 as total_lessons + FROM lessons + WHERE module_id IN (SELECT id FROM modules WHERE course_id = p_course_id) + ) + SELECT + cs.v_period as period, + COUNT(DISTINCT cs.user_id) as student_count, + COALESCE(AVG( + (SELECT COUNT(DISTINCT lesson_id)::float4 FROM user_grades WHERE user_id = cs.user_id AND course_id = p_course_id) / + NULLIF((SELECT total_lessons FROM course_lesson_count), 0) + ), 0)::float4 as completion_rate + FROM cohort_students cs + GROUP BY cs.v_period + ORDER BY cs.v_period DESC; +END; +$$ LANGUAGE plpgsql; + +-- 3. Retention Data Function +CREATE OR REPLACE FUNCTION fn_get_retention_data(p_course_id UUID) +RETURNS TABLE ( + lesson_id UUID, + lesson_title VARCHAR, + student_count BIGINT +) AS $$ +BEGIN + RETURN QUERY + SELECT + l.id as lesson_id, + l.title as lesson_title, + COUNT(DISTINCT ug.user_id) as student_count + FROM lessons l + LEFT JOIN user_grades ug ON l.id = ug.lesson_id + WHERE l.module_id IN (SELECT id FROM modules WHERE course_id = p_course_id) + GROUP BY l.id, l.title, l.position + ORDER BY l.position; +END; +$$ LANGUAGE plpgsql; + +-- 4. Enrollment Function +CREATE OR REPLACE FUNCTION fn_enroll_student( + p_organization_id UUID, + p_user_id UUID, + p_course_id UUID +) RETURNS SETOF enrollments AS $$ +BEGIN + RETURN QUERY + INSERT INTO enrollments (organization_id, user_id, course_id) + VALUES (p_organization_id, p_user_id, p_course_id) + ON CONFLICT (user_id, course_id) DO UPDATE SET enrolled_at = NOW() + RETURNING *; +END; +$$ LANGUAGE plpgsql; + +-- 5. Grading Function (Upsert) with Automated Logic +CREATE OR REPLACE FUNCTION fn_upsert_user_grade( + p_organization_id UUID, + p_user_id UUID, + p_course_id UUID, + p_lesson_id UUID, + p_score FLOAT4, + p_metadata JSONB DEFAULT NULL +) RETURNS SETOF user_grades AS $$ +DECLARE + v_grade user_grades; + v_xp_amount INTEGER := 20; -- Default XP for completion + v_badge_id UUID; +BEGIN + -- 1. Upsert grade + INSERT INTO user_grades (organization_id, user_id, course_id, lesson_id, score, metadata, attempts_count) + VALUES (p_organization_id, p_user_id, p_course_id, p_lesson_id, p_score, p_metadata, 1) + ON CONFLICT (user_id, lesson_id) DO UPDATE SET + score = EXCLUDED.score, + metadata = EXCLUDED.metadata, + attempts_count = user_grades.attempts_count + 1, + updated_at = NOW() + RETURNING * INTO v_grade; + + -- 2. Award XP automatically + PERFORM fn_award_xp(p_user_id, p_organization_id, v_xp_amount, 'lesson_completion', 'lesson', p_lesson_id); + + -- 3. Check for new badges + FOR v_badge_id IN + SELECT id FROM badges + WHERE organization_id = p_organization_id + AND requirement_type = 'points' + AND requirement_value <= (SELECT xp FROM users WHERE id = p_user_id) + AND id NOT IN (SELECT badge_id FROM user_badges WHERE user_id = p_user_id) + LOOP + INSERT INTO user_badges (user_id, badge_id) VALUES (p_user_id, v_badge_id) ON CONFLICT DO NOTHING; + END LOOP; + + RETURN NEXT v_grade; +END; +$$ LANGUAGE plpgsql; diff --git a/services/lms-service/migrations/20260111000003_lms_webhooks.sql b/services/lms-service/migrations/20260111000003_lms_webhooks.sql new file mode 100644 index 0000000..919d255 --- /dev/null +++ b/services/lms-service/migrations/20260111000003_lms_webhooks.sql @@ -0,0 +1,14 @@ +-- Migration: Create Webhooks Table for LMS +CREATE TABLE webhooks ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + organization_id UUID NOT NULL REFERENCES organizations(id) ON DELETE CASCADE, + url VARCHAR(500) NOT NULL, + events VARCHAR(50)[] NOT NULL, -- e.g., ['user.enrolled', 'lesson.completed', 'course.completed'] + secret VARCHAR(255), -- For HMAC-SHA256 signatures + is_active BOOLEAN NOT NULL DEFAULT TRUE, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- Index for organization_id +CREATE INDEX idx_webhooks_organization_id ON webhooks(organization_id); diff --git a/services/lms-service/src/db_util.rs b/services/lms-service/src/db_util.rs new file mode 100644 index 0000000..296b20d --- /dev/null +++ b/services/lms-service/src/db_util.rs @@ -0,0 +1,40 @@ +use sqlx::{Postgres, Transaction}; + +pub async fn set_session_context( + tx: &mut Transaction<'_, Postgres>, + user_id: Option, + org_id: Option, + ip: Option, + ua: Option, + event_type: Option, +) -> Result<(), sqlx::Error> { + if let Some(uid) = user_id { + sqlx::query(&format!("SET LOCAL app.current_user_id = '{}'", uid)) + .execute(&mut **tx) + .await?; + } + if let Some(oid) = org_id { + sqlx::query(&format!("SET LOCAL app.current_org_id = '{}'", oid)) + .execute(&mut **tx) + .await?; + } + if let Some(ip_addr) = ip { + sqlx::query(&format!("SET LOCAL app.client_ip = '{}'", ip_addr)) + .execute(&mut **tx) + .await?; + } + if let Some(user_agent) = ua { + // Use set_config for potentially long strings to avoid SQL injection/formatting issues + sqlx::query("SELECT set_config('app.user_agent', $1, true)") + .bind(user_agent) + .execute(&mut **tx) + .await?; + } + if let Some(et) = event_type { + sqlx::query("SELECT set_config('app.event_type', $1, true)") + .bind(et) + .execute(&mut **tx) + .await?; + } + Ok(()) +} diff --git a/services/lms-service/src/handlers.rs b/services/lms-service/src/handlers.rs index d5363f3..9196e14 100644 --- a/services/lms-service/src/handlers.rs +++ b/services/lms-service/src/handlers.rs @@ -15,9 +15,10 @@ use sqlx::{PgPool, Row}; use uuid::Uuid; pub async fn enroll_user( - State(pool): State, Org(org_ctx): Org, claims: Claims, + State(pool): State, + headers: axum::http::HeaderMap, Json(payload): Json, ) -> Result, StatusCode> { let course_id_str = payload @@ -27,18 +28,61 @@ pub async fn enroll_user( let course_id = Uuid::parse_str(course_id_str).map_err(|_| StatusCode::BAD_REQUEST)?; let user_id = claims.sub; - let enrollment = sqlx::query_as::<_, Enrollment>( - "INSERT INTO enrollments (user_id, course_id, organization_id) VALUES ($1, $2, $3) RETURNING *" + let mut tx = pool + .begin() + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + + let ip = headers + .get("x-forwarded-for") + .and_then(|h| h.to_str().ok()) + .or_else(|| headers.get("x-real-ip").and_then(|h| h.to_str().ok())) + .map(|s| s.to_string()); + + let ua = headers + .get("user-agent") + .and_then(|h| h.to_str().ok()) + .map(|s| s.to_string()); + + crate::db_util::set_session_context( + &mut tx, + Some(user_id), + Some(org_ctx.id), + ip, + ua, + Some("USER_EVENT".to_string()), ) - .bind(user_id) - .bind(course_id) - .bind(org_ctx.id) - .fetch_one(&pool) .await - .map_err(|e| { - tracing::error!("Enrollment failed: {}", e); - StatusCode::INTERNAL_SERVER_ERROR - })?; + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + + let enrollment = sqlx::query_as::<_, Enrollment>("SELECT * FROM fn_enroll_student($1, $2, $3)") + .bind(org_ctx.id) + .bind(user_id) + .bind(course_id) + .fetch_one(&mut *tx) + .await + .map_err(|e| { + tracing::error!("Enrollment failed: {}", e); + StatusCode::INTERNAL_SERVER_ERROR + })?; + + tx.commit() + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + + // Dispatch Webhook + let webhook_service = common::webhooks::WebhookService::new(pool.clone()); + webhook_service + .dispatch( + org_ctx.id, + "user.enrolled", + &serde_json::json!({ + "user_id": user_id, + "course_id": course_id, + "enrollment_id": enrollment.id + }), + ) + .await; Ok(Json(enrollment)) } @@ -397,16 +441,45 @@ pub async fn get_user_enrollments( pub async fn submit_lesson_score( Org(org_ctx): Org, + claims: Claims, State(pool): State, + headers: axum::http::HeaderMap, Json(payload): Json, ) -> Result, (StatusCode, String)> { + let mut tx = pool + .begin() + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + + let ip = headers + .get("x-forwarded-for") + .and_then(|h| h.to_str().ok()) + .or_else(|| headers.get("x-real-ip").and_then(|h| h.to_str().ok())) + .map(|s| s.to_string()); + + let ua = headers + .get("user-agent") + .and_then(|h| h.to_str().ok()) + .map(|s| s.to_string()); + + crate::db_util::set_session_context( + &mut tx, + Some(claims.sub), + Some(org_ctx.id), + ip, + ua, + Some("SYSTEM_EVENT".to_string()), + ) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + // 1. Get lesson attempt rules let max_attempts: Option> = sqlx::query_scalar( "SELECT max_attempts FROM lessons WHERE id = $1 AND organization_id = $2", ) .bind(payload.lesson_id) .bind(org_ctx.id) - .fetch_optional(&pool) + .fetch_optional(&mut *tx) .await .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; @@ -420,19 +493,13 @@ pub async fn submit_lesson_score( .bind(payload.user_id) .bind(payload.lesson_id) .bind(org_ctx.id) - .fetch_optional(&pool) + .fetch_optional(&mut *tx) .await .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; if let Some(count) = existing_attempts { if let Some(max) = max_attempts { if count >= max { - tracing::warn!( - "User {} attempted to resubmit lesson {} but reached max_attempts ({})", - payload.user_id, - payload.lesson_id, - max - ); return Err(( StatusCode::FORBIDDEN, "Maximum attempts reached for this assessment".into(), @@ -441,68 +508,66 @@ pub async fn submit_lesson_score( } } - // 3. Upsert with increment + // 3. Upsert with automated DB logic (XP, Badges) let grade = sqlx::query_as::<_, common::models::UserGrade>( - "INSERT INTO user_grades (user_id, course_id, lesson_id, score, metadata, attempts_count, organization_id) - VALUES ($1, $2, $3, $4, $5, 1, $6) - ON CONFLICT (user_id, lesson_id) DO UPDATE SET - score = EXCLUDED.score, - metadata = EXCLUDED.metadata, - attempts_count = user_grades.attempts_count + 1, - created_at = CURRENT_TIMESTAMP - RETURNING *" + "SELECT * FROM fn_upsert_user_grade($1, $2, $3, $4, $5, $6)", ) + .bind(org_ctx.id) .bind(payload.user_id) .bind(payload.course_id) .bind(payload.lesson_id) .bind(payload.score) .bind(payload.metadata) - .bind(org_ctx.id) - .fetch_one(&pool) + .fetch_one(&mut *tx) .await .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; - // 4. Grant Points - let points_to_grant = 20; // Base points for any lesson - let _ = sqlx::query( - "INSERT INTO points_log (user_id, organization_id, amount, reason, entity_type, entity_id) VALUES ($1, $2, $3, $4, $5, $6)" + tx.commit() + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + + // 4. Dispatch Webhooks + let webhook_service = common::webhooks::WebhookService::new(pool.clone()); + + // lesson.completed + webhook_service + .dispatch( + org_ctx.id, + "lesson.completed", + &serde_json::json!({ + "user_id": payload.user_id, + "course_id": payload.course_id, + "lesson_id": payload.lesson_id, + "score": payload.score + }), + ) + .await; + + // TODO: Detect course completion logic + let total_lessons: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM lessons WHERE module_id IN (SELECT id FROM modules WHERE course_id = $1)") + .bind(payload.course_id) + .fetch_one(&pool).await.unwrap_or(0); + + let completed_lessons: i64 = sqlx::query_scalar( + "SELECT COUNT(*) FROM user_grades WHERE user_id = $1 AND course_id = $2", ) .bind(payload.user_id) - .bind(org_ctx.id) - .bind(points_to_grant) - .bind("lesson_completion") - .bind("lesson") - .bind(payload.lesson_id) - .execute(&pool) - .await; + .bind(payload.course_id) + .fetch_one(&pool) + .await + .unwrap_or(0); - // 5. Check for new badges (Trigger-like logic in code) - // For now, very simple: if they reached a points threshold - let total_points: i64 = - sqlx::query_scalar("SELECT COALESCE(SUM(amount), 0) FROM points_log WHERE user_id = $1") - .bind(payload.user_id) - .fetch_one(&pool) - .await - .unwrap_or(0); - - let eligible_badges = sqlx::query( - "SELECT id FROM badges WHERE organization_id = $1 AND requirement_type = 'points' AND requirement_value <= $2 AND id NOT IN (SELECT badge_id FROM user_badges WHERE user_id = $3)" - ) - .bind(org_ctx.id) - .bind(total_points as i32) - .bind(payload.user_id) - .fetch_all(&pool) - .await; - - if let Ok(new_badges) = eligible_badges { - for b in new_badges { - let badge_id: Uuid = b.get("id"); - let _ = sqlx::query("INSERT INTO user_badges (user_id, badge_id) VALUES ($1, $2) ON CONFLICT DO NOTHING") - .bind(payload.user_id) - .bind(badge_id) - .execute(&pool) - .await; - } + if total_lessons > 0 && completed_lessons >= total_lessons { + webhook_service + .dispatch( + org_ctx.id, + "course.completed", + &serde_json::json!({ + "user_id": payload.user_id, + "course_id": payload.course_id + }), + ) + .await; } Ok(Json(grade)) @@ -511,6 +576,7 @@ pub async fn submit_lesson_score( #[derive(serde::Serialize)] pub struct GamificationStatus { pub points: i64, + pub level: i32, pub badges: Vec, } @@ -528,12 +594,11 @@ pub async fn get_user_gamification( State(pool): State, Path(user_id): Path, ) -> Result, StatusCode> { - let points: i64 = - sqlx::query_scalar("SELECT COALESCE(SUM(amount), 0) FROM points_log WHERE user_id = $1") - .bind(user_id) - .fetch_one(&pool) - .await - .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + let user_stats: (i32, i32) = sqlx::query_as("SELECT xp, level FROM users WHERE id = $1") + .bind(user_id) + .fetch_one(&pool) + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; let badges = sqlx::query_as::<_, BadgeResponse>( "SELECT b.id, b.name, b.description, b.icon_url, ub.earned_at @@ -546,7 +611,42 @@ pub async fn get_user_gamification( .await .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; - Ok(Json(GamificationStatus { points, badges })) + Ok(Json(GamificationStatus { + points: user_stats.0 as i64, + level: user_stats.1, + badges, + })) +} + +pub async fn get_leaderboard( + Org(org_ctx): Org, + State(pool): State, +) -> Result>, StatusCode> { + let top_users = sqlx::query_as::<_, User>( + "SELECT * FROM users WHERE organization_id = $1 ORDER BY xp DESC LIMIT 10", + ) + .bind(org_ctx.id) + .fetch_all(&pool) + .await + .map_err(|e| { + tracing::error!("Failed to fetch leaderboard: {}", e); + StatusCode::INTERNAL_SERVER_ERROR + })?; + + let response = top_users + .into_iter() + .map(|u| UserResponse { + id: u.id, + email: u.email, + full_name: u.full_name, + role: u.role, + organization_id: u.organization_id, + xp: u.xp, + level: u.level, + }) + .collect(); + + Ok(Json(response)) } pub async fn get_user_course_grades( @@ -630,3 +730,38 @@ pub async fn get_course_analytics( lessons, })) } + +pub async fn get_advanced_analytics( + Org(_org_ctx): Org, + State(pool): State, + Path(course_id): Path, +) -> Result, StatusCode> { + // 1. Cohort Analysis using DB function + let cohort_data = sqlx::query_as::<_, common::models::CohortData>( + "SELECT period, student_count as count, completion_rate FROM fn_get_cohort_analytics($1)", + ) + .bind(course_id) + .fetch_all(&pool) + .await + .map_err(|e| { + tracing::error!("Cohort query failed: {}", e); + StatusCode::INTERNAL_SERVER_ERROR + })?; + + // 2. Retention Analysis using DB function + let retention_data = sqlx::query_as::<_, common::models::RetentionData>( + "SELECT lesson_id, lesson_title, student_count FROM fn_get_retention_data($1)", + ) + .bind(course_id) + .fetch_all(&pool) + .await + .map_err(|e| { + tracing::error!("Retention query failed: {}", e); + StatusCode::INTERNAL_SERVER_ERROR + })?; + + Ok(Json(common::models::AdvancedAnalytics { + cohorts: cohort_data, + retention: retention_data, + })) +} diff --git a/services/lms-service/src/main.rs b/services/lms-service/src/main.rs index f0dd889..306bf7e 100644 --- a/services/lms-service/src/main.rs +++ b/services/lms-service/src/main.rs @@ -1,15 +1,15 @@ +mod db_util; mod handlers; use axum::{ + Router, middleware, routing::{get, post}, - Router, - middleware, }; -use tower_http::cors::{Any, CorsLayer}; -use sqlx::postgres::PgPoolOptions; -use std::net::SocketAddr; use dotenvy::dotenv; +use sqlx::postgres::PgPoolOptions; use std::env; +use std::net::SocketAddr; +use tower_http::cors::{Any, CorsLayer}; #[tokio::main] async fn main() { @@ -40,10 +40,26 @@ async fn main() { .route("/courses/{id}/outline", get(handlers::get_course_outline)) .route("/lessons/{id}", get(handlers::get_lesson_content)) .route("/grades", post(handlers::submit_lesson_score)) - .route("/users/{user_id}/courses/{course_id}/grades", get(handlers::get_user_course_grades)) - .route("/courses/{id}/analytics", get(handlers::get_course_analytics)) - .route("/users/{id}/gamification", get(handlers::get_user_gamification)) - .route_layer(middleware::from_fn(common::middleware::org_extractor_middleware)); + .route( + "/users/{user_id}/courses/{course_id}/grades", + get(handlers::get_user_course_grades), + ) + .route( + "/courses/{id}/analytics", + get(handlers::get_course_analytics), + ) + .route( + "/courses/{id}/analytics/advanced", + get(handlers::get_advanced_analytics), + ) + .route( + "/users/{id}/gamification", + get(handlers::get_user_gamification), + ) + .route("/analytics/leaderboard", get(handlers::get_leaderboard)) + .route_layer(middleware::from_fn( + common::middleware::org_extractor_middleware, + )); let public_routes = Router::new() .route("/catalog", get(handlers::get_course_catalog)) diff --git a/shared/common/Cargo.toml b/shared/common/Cargo.toml index 4c0bc3f..8a815df 100644 --- a/shared/common/Cargo.toml +++ b/shared/common/Cargo.toml @@ -13,3 +13,8 @@ chrono.workspace = true uuid.workspace = true jsonwebtoken.workspace = true bcrypt.workspace = true +reqwest = { workspace = true, features = ["json"] } +hmac.workspace = true +sha2.workspace = true +hex.workspace = true +tracing.workspace = true diff --git a/shared/common/src/lib.rs b/shared/common/src/lib.rs index 17add37..a2e1906 100644 --- a/shared/common/src/lib.rs +++ b/shared/common/src/lib.rs @@ -1,4 +1,5 @@ -pub mod models; pub mod auth; -pub mod utils; pub mod middleware; +pub mod models; +pub mod utils; +pub mod webhooks; diff --git a/shared/common/src/models.rs b/shared/common/src/models.rs index b49450b..c975a4e 100644 --- a/shared/common/src/models.rs +++ b/shared/common/src/models.rs @@ -185,6 +185,36 @@ pub struct LessonAnalytics { pub average_score: f32, // 0.0-1.0 pub submission_count: i64, } +#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)] +pub struct CohortData { + pub period: String, + pub count: i64, + pub completion_rate: f32, +} + +#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)] +pub struct RetentionData { + pub lesson_id: Uuid, + pub lesson_title: String, + pub student_count: i64, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AdvancedAnalytics { + pub cohorts: Vec, + pub retention: Vec, +} +#[derive(Debug, Serialize, Deserialize, sqlx::FromRow, Clone)] +pub struct Webhook { + pub id: Uuid, + pub organization_id: Uuid, + pub url: String, + pub events: Vec, + pub secret: Option, + pub is_active: bool, + pub created_at: DateTime, + pub updated_at: DateTime, +} #[cfg(test)] mod tests { diff --git a/shared/common/src/webhooks.rs b/shared/common/src/webhooks.rs new file mode 100644 index 0000000..fdde3ef --- /dev/null +++ b/shared/common/src/webhooks.rs @@ -0,0 +1,84 @@ +use crate::models::Webhook; +use hmac::{Hmac, Mac}; +use sha2::Sha256; +use sqlx::PgPool; +use uuid::Uuid; + +pub struct WebhookService { + pool: PgPool, +} + +impl WebhookService { + pub fn new(pool: PgPool) -> Self { + Self { pool } + } + + pub async fn dispatch(&self, org_id: Uuid, event_type: &str, payload: &serde_json::Value) { + let webhooks = match sqlx::query_as::<_, Webhook>( + "SELECT * FROM webhooks WHERE organization_id = $1 AND is_active = TRUE AND $2 = ANY(events)" + ) + .bind(org_id) + .bind(event_type) + .fetch_all(&self.pool) + .await { + Ok(w) => w, + Err(e) => { + tracing::error!("Failed to fetch webhooks for org {}: {}", org_id, e); + return; + } + }; + + if webhooks.is_empty() { + return; + } + + let client = reqwest::Client::new(); + let payload_str = payload.to_string(); + + for webhook in webhooks { + let mut request = client + .post(&webhook.url) + .header("Content-Type", "application/json") + .header("X-OpenCCB-Event", event_type) + .header("X-OpenCCB-Delivery", Uuid::new_v4().to_string()); + + if let Some(secret) = &webhook.secret { + let signature = generate_signature(secret, &payload_str); + request = request.header("X-OpenCCB-Signature", signature); + } + + let url = webhook.url.clone(); + let res = request.body(payload_str.clone()).send().await; + + match res { + Ok(response) => { + if !response.status().is_success() { + tracing::warn!( + "Webhook delivery to {} (event: {}) failed with status {}", + url, + event_type, + response.status() + ); + } + } + Err(e) => { + tracing::error!( + "Failed to deliver webhook to {} (event: {}): {}", + url, + event_type, + e + ); + } + } + } + } +} + +fn generate_signature(secret: &str, payload: &str) -> String { + type HmacSha256 = Hmac; + let mut mac = + HmacSha256::new_from_slice(secret.as_bytes()).expect("HMAC can take key of any size"); + mac.update(payload.as_bytes()); + let result = mac.finalize(); + hex::encode(result.into_bytes()) +} diff --git a/validate_auth.sh b/validate_auth.sh index cbe361e..f1ed169 100755 --- a/validate_auth.sh +++ b/validate_auth.sh @@ -19,7 +19,7 @@ fi # 2. Verify New Registration echo "Testing Registration for newuser@test.com..." # Clear if exists -docker exec openccb-1-db-1 psql -U user -d openccb_cms -c "DELETE FROM users WHERE email='newuser@test.com';" > /dev/null 2>&1 +docker exec openccb-db-1 psql -U user -d openccb_cms -c "DELETE FROM users WHERE email='newuser@test.com';" > /dev/null 2>&1 HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" -X POST http://localhost:3001/auth/register \ -H "Content-Type: application/json" \ @@ -28,7 +28,7 @@ HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" -X POST http://localhost:3001 if [ "$HTTP_CODE" -eq 200 ]; then echo "SUCCESS: Registration worked for newuser@test.com" # Cleanup - docker exec openccb-1-db-1 psql -U user -d openccb_cms -c "DELETE FROM users WHERE email='newuser@test.com';" > /dev/null 2>&1 + docker exec openccb-db-1 psql -U user -d openccb_cms -c "DELETE FROM users WHERE email='newuser@test.com';" > /dev/null 2>&1 else echo "FAIL: Registration failed with status $HTTP_CODE" curl -s -X POST http://localhost:3001/auth/register \ diff --git a/web/experience/Dockerfile b/web/experience/Dockerfile index 735a502..50d7c1b 100644 --- a/web/experience/Dockerfile +++ b/web/experience/Dockerfile @@ -1,20 +1,37 @@ -# Build stage -FROM node:18-alpine AS builder -WORKDIR /app -COPY package*.json ./ -RUN npm install +# Build stage for Rust LMS +FROM rustlang/rust:nightly AS rust-builder +WORKDIR /usr/src/app COPY . . +RUN apt-get update && apt-get install -y pkg-config libssl-dev && rm -rf /var/lib/apt/lists/* +RUN cargo build --release -p lms-service + +# Build stage for Next.js Experience +FROM node:18-alpine AS node-builder +WORKDIR /app +COPY web/experience/package*.json ./ +RUN npm install +COPY web/experience/ . RUN npm run build -# Production stage +# Final stage FROM node:18-alpine AS runner WORKDIR /app ENV NODE_ENV production -ENV PORT 3003 -COPY --from=builder /app/public ./public -COPY --from=builder /app/.next/standalone ./ -COPY --from=builder /app/.next/static ./.next/static +# Install system dependencies for Rust binary +RUN apk add --no-cache openssl libgcc libstdc++ -EXPOSE 3003 -CMD ["node", "server.js"] +# Copy LMS binary +COPY --from=rust-builder /usr/src/app/target/release/lms-service ./ + +# Copy Experience frontend +COPY --from=node-builder /app/public ./public +COPY --from=node-builder /app/.next/standalone ./ +COPY --from=node-builder /app/.next/static ./.next/static + +# Copy entrypoint +COPY web/experience/entrypoint.sh ./ +RUN chmod +x entrypoint.sh + +EXPOSE 3003 3002 +CMD ["./entrypoint.sh"] diff --git a/web/experience/README.md b/web/experience/README.md deleted file mode 100644 index e215bc4..0000000 --- a/web/experience/README.md +++ /dev/null @@ -1,36 +0,0 @@ -This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app). - -## Getting Started - -First, run the development server: - -```bash -npm run dev -# or -yarn dev -# or -pnpm dev -# or -bun dev -``` - -Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. - -You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file. - -This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel. - -## Learn More - -To learn more about Next.js, take a look at the following resources: - -- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API. -- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial. - -You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome! - -## Deploy on Vercel - -The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js. - -Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details. diff --git a/web/experience/entrypoint.sh b/web/experience/entrypoint.sh new file mode 100644 index 0000000..f93e1bb --- /dev/null +++ b/web/experience/entrypoint.sh @@ -0,0 +1,7 @@ +#!/bin/sh + +# Start the LMS backend in the background +./lms-service & + +# Start the Experience frontend +node server.js diff --git a/web/experience/src/app/page.tsx b/web/experience/src/app/page.tsx index 030cfcd..da4fd2c 100644 --- a/web/experience/src/app/page.tsx +++ b/web/experience/src/app/page.tsx @@ -5,13 +5,14 @@ import { lmsApi, Course, Lesson } from "@/lib/api"; import Link from "next/link"; import { useAuth } from "@/context/AuthContext"; import { useRouter } from "next/navigation"; -import { Rocket, CheckCircle2, ArrowRight, Star, Calendar, Clock, AlertCircle } from "lucide-react"; +import { Rocket, CheckCircle2, ArrowRight, Star, Calendar, Clock, AlertCircle, Zap, TrendingUp } from "lucide-react"; +import Leaderboard from "@/components/Leaderboard"; export default function CatalogPage() { const [courses, setCourses] = useState([]); const [enrollments, setEnrollments] = useState([]); const [loading, setLoading] = useState(true); - const [gamification, setGamification] = useState<{ points: number, badges: any[] } | null>(null); + const [gamification, setGamification] = useState<{ points: number, level: number, badges: any[] } | null>(null); const [upcomingDeadlines, setUpcomingDeadlines] = useState<{ lesson: Lesson, courseTitle: string }[]>([]); const { user } = useAuth(); @@ -106,34 +107,71 @@ export default function CatalogPage() { {user && gamification && ( -
-
-
- +
+
+
+
+
+
+ +
+
+ {gamification.level} +
+
+ +
+
+
Current Standing
+

Level {gamification.level} Pioneer

+
+ +
+
+
+ {gamification.points} / {Math.pow(gamification.level, 2) * 100} XP +
+
+ {Math.floor(((gamification.points - Math.pow(gamification.level - 1, 2) * 100) / (Math.pow(gamification.level, 2) * 100 - Math.pow(gamification.level - 1, 2) * 100)) * 100)}% to Level {gamification.level + 1} +
+
+
+
+
+
+
+
+ + {/* Background Flair */} +
+
+ +
+

+ My Badges +

+
+ {gamification.badges.length === 0 ? ( +

No badges earned yet. Start learning to unlock achievements!

+ ) : ( + gamification.badges.map(badge => ( +
+
+ 🏆 +
+
+
+ )) + )} +
-
{gamification.points}
-
Total Experience Points
-
-

- My Badges -

-
- {gamification.badges.length === 0 ? ( -

No badges earned yet. Start learning to unlock achievements!

- ) : ( - gamification.badges.map(badge => ( -
-
- 🏆 -
-
-
- )) - )} -
- {/* Visual Flair */} +
+
)} diff --git a/web/experience/src/components/Leaderboard.tsx b/web/experience/src/components/Leaderboard.tsx new file mode 100644 index 0000000..962028e --- /dev/null +++ b/web/experience/src/components/Leaderboard.tsx @@ -0,0 +1,77 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { lmsApi, User } from "@/lib/api"; +import { Trophy, Medal, Award } from "lucide-react"; + +export default function Leaderboard() { + const [topUsers, setTopUsers] = useState([]); + const [loading, setLoading] = useState(true); + + useEffect(() => { + const fetchLeaderboard = async () => { + try { + const data = await lmsApi.getLeaderboard(); + setTopUsers(data); + } catch (err) { + console.error("Failed to fetch leaderboard", err); + } finally { + setLoading(false); + } + }; + fetchLeaderboard(); + }, []); + + if (loading) { + return
+ {[1, 2, 3].map(i => ( +
+ ))} +
; + } + + return ( +
+

+ Leaderboard +

+ +
+ {topUsers.map((user, index) => ( +
+
+ {index === 0 ? : + index === 1 ? : + index === 2 ? : + index + 1} +
+ +
+
{user.full_name}
+
Level {user.level || 1}
+
+ +
+
{user.xp || 0}
+
XP
+
+
+ ))} + + {topUsers.length === 0 && ( +
+ No ranking data available yet. +
+ )} +
+ + {/* Visual background flair */} +
+
+ ); +} diff --git a/web/experience/src/lib/api.ts b/web/experience/src/lib/api.ts index e9e0e84..7db406c 100644 --- a/web/experience/src/lib/api.ts +++ b/web/experience/src/lib/api.ts @@ -96,6 +96,8 @@ export interface User { email: string; full_name: string; role: string; + xp?: number; + level?: number; } export interface AuthResponse { @@ -187,12 +189,21 @@ export const lmsApi = { return response.json(); }, - async getGamification(userId: string): Promise<{ points: number, badges: { id: string, name: string, description: string }[] }> { + async getGamification(userId: string): Promise<{ points: number, level: number, badges: { id: string, name: string, description: string, earned_at: string }[] }> { const response = await fetch(`${API_BASE_URL}/users/${userId}/gamification`); if (!response.ok) throw new Error('Failed to fetch gamification data'); return response.json(); }, + async getLeaderboard(): Promise { + const token = localStorage.getItem('token'); + const response = await fetch(`${API_BASE_URL}/analytics/leaderboard`, { + headers: { 'Authorization': `Bearer ${token}` } + }); + if (!response.ok) throw new Error('Failed to fetch leaderboard'); + return response.json(); + }, + async getBranding(orgId: string): Promise { const response = await fetch(`${CMS_API_URL}/organizations/${orgId}/branding`); if (!response.ok) throw new Error('Failed to fetch branding'); diff --git a/web/studio/Dockerfile b/web/studio/Dockerfile index 7061264..0bd5354 100644 --- a/web/studio/Dockerfile +++ b/web/studio/Dockerfile @@ -1,19 +1,37 @@ -# Build stage -FROM node:18-alpine AS builder -WORKDIR /app -COPY package*.json ./ -RUN npm install +# Build stage for Rust CMS +FROM rustlang/rust:nightly AS rust-builder +WORKDIR /usr/src/app COPY . . +RUN apt-get update && apt-get install -y pkg-config libssl-dev && rm -rf /var/lib/apt/lists/* +RUN cargo build --release -p cms-service + +# Build stage for Next.js Studio +FROM node:18-alpine AS node-builder +WORKDIR /app +COPY web/studio/package*.json ./ +RUN npm install +COPY web/studio/ . RUN npm run build -# Production stage +# Final stage FROM node:18-alpine AS runner WORKDIR /app ENV NODE_ENV production -COPY --from=builder /app/public ./public -COPY --from=builder /app/.next/standalone ./ -COPY --from=builder /app/.next/static ./.next/static +# Install system dependencies for Rust binary +RUN apk add --no-cache openssl libgcc libstdc++ -EXPOSE 3000 -CMD ["node", "server.js"] +# Copy CMS binary +COPY --from=rust-builder /usr/src/app/target/release/cms-service ./ + +# Copy Studio frontend +COPY --from=node-builder /app/public ./public +COPY --from=node-builder /app/.next/standalone ./ +COPY --from=node-builder /app/.next/static ./.next/static + +# Copy entrypoint +COPY web/studio/entrypoint.sh ./ +RUN chmod +x entrypoint.sh + +EXPOSE 3000 3001 +CMD ["./entrypoint.sh"] diff --git a/web/studio/README.md b/web/studio/README.md deleted file mode 100644 index e215bc4..0000000 --- a/web/studio/README.md +++ /dev/null @@ -1,36 +0,0 @@ -This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app). - -## Getting Started - -First, run the development server: - -```bash -npm run dev -# or -yarn dev -# or -pnpm dev -# or -bun dev -``` - -Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. - -You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file. - -This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel. - -## Learn More - -To learn more about Next.js, take a look at the following resources: - -- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API. -- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial. - -You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome! - -## Deploy on Vercel - -The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js. - -Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details. diff --git a/web/studio/entrypoint.sh b/web/studio/entrypoint.sh new file mode 100644 index 0000000..d5af766 --- /dev/null +++ b/web/studio/entrypoint.sh @@ -0,0 +1,7 @@ +#!/bin/sh + +# Start the CMS backend in the background +./cms-service & + +# Start the Studio frontend +node server.js diff --git a/web/studio/src/app/courses/[id]/analytics/advanced/page.tsx b/web/studio/src/app/courses/[id]/analytics/advanced/page.tsx new file mode 100644 index 0000000..c3a4054 --- /dev/null +++ b/web/studio/src/app/courses/[id]/analytics/advanced/page.tsx @@ -0,0 +1,194 @@ +"use client"; + +import React, { useState, useEffect } from "react"; +import { useParams, useRouter } from "next/navigation"; +import { cmsApi, Course, AdvancedAnalytics } from "@/lib/api"; +import { useAuth } from "@/context/AuthContext"; +import { + LineChart, + BarChart3, + Users, + TrendingUp, + ArrowLeft, + Layers, + Calendar, + Filter +} from "lucide-react"; +import CourseEditorLayout from "@/components/CourseEditorLayout"; + +export default function AdvancedAnalyticsPage() { + const { id } = useParams() as { id: string }; + const router = useRouter(); + const { user } = useAuth(); + const [course, setCourse] = useState(null); + const [analytics, setAnalytics] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + const fetchData = async () => { + if (!user) return; + try { + const [courseData, advancedData] = await Promise.all([ + cmsApi.getCourseWithFullOutline(id), + cmsApi.getAdvancedAnalytics(id) + ]); + setCourse(courseData); + setAnalytics(advancedData); + } catch (err: any) { + console.error("Failed to load advanced analytics", err); + setError(err.message || "Failed to load data"); + } finally { + setLoading(false); + } + }; + fetchData(); + }, [id, user]); + + if (loading) return ( +
+
+
+ ); + + if (error || !course || !analytics) return ( +
+
{error || "Data unavailable"}
+ +
+ ); + + return ( +
+
+ {/* Header */} +
+
+ +
+

+ Advanced Insights +

+

Cohort analysis and student retention for {course.title}

+
+
+
+ + +
+ {/* Cohort Analysis */} +
+
+

+ + Cohort Completion +

+
+ Grouped by Month +
+
+ +
+ + + + + + + + + + + {analytics.cohorts.length === 0 ? ( + + + + ) : analytics.cohorts.map((cohort) => ( + + + + + + + ))} + +
Cohort (Enrollment Month)StudentsAvg. Completion RateEngagement Status
No cohort data available yet.
+ + {cohort.period} + {cohort.count} +
+
+
+
+ {Math.round(cohort.completion_rate * 100)}% +
+
+ {cohort.completion_rate > 0.8 ? ( + Excellent + ) : cohort.completion_rate > 0.5 ? ( + Healthy + ) : ( + Low Momentum + )} +
+
+
+ + {/* Retention Analysis */} +
+

+ + Retention Heatmap +

+
+ {analytics.retention.map((item, index) => { + const firstStudentCount = analytics.retention[0]?.student_count || 1; + const percentage = (item.student_count / firstStudentCount) * 100; + + return ( +
+
+
+
+ {index + 1} +
+ {item.lesson_title} +
+
+
{item.student_count} Students
+
{Math.round(percentage)}% Retention
+
+
+
+
80 ? 'bg-indigo-500' : + percentage > 50 ? 'bg-indigo-600/70' : + 'bg-indigo-700/40' + }`} + style={{ width: `${percentage}%` }} + /> +
+ {index > 0 && analytics.retention[index - 1].student_count > 0 && ( +
+ -{Math.round(100 - (item.student_count / analytics.retention[index - 1].student_count) * 100)}% drop +
+ )} +
+ ); + })} +
+
+
+
+
+
+ ); +} diff --git a/web/studio/src/app/courses/[id]/analytics/page.tsx b/web/studio/src/app/courses/[id]/analytics/page.tsx index 4508600..fc556e3 100644 --- a/web/studio/src/app/courses/[id]/analytics/page.tsx +++ b/web/studio/src/app/courses/[id]/analytics/page.tsx @@ -11,7 +11,8 @@ import { AlertTriangle, ArrowLeft, CheckCircle2, - BookOpen + BookOpen, + Layers } from "lucide-react"; import CourseEditorLayout from "@/components/CourseEditorLayout"; @@ -100,8 +101,16 @@ export default function AnalyticsPage() {

Performance insights and student progress for {course?.title}

-
- {user?.role} View +
+ +
+ {user?.role} View +
diff --git a/web/studio/src/app/settings/webhooks/page.tsx b/web/studio/src/app/settings/webhooks/page.tsx new file mode 100644 index 0000000..ad12943 --- /dev/null +++ b/web/studio/src/app/settings/webhooks/page.tsx @@ -0,0 +1,250 @@ +"use client"; + +import React, { useState, useEffect } from "react"; +import { cmsApi, Webhook, CreateWebhookPayload } from "@/lib/api"; +import { useAuth } from "@/context/AuthContext"; +import { + Webhook as WebhookIcon, + Plus, + Trash2, + CheckCircle2, + AlertCircle, + Shield, + Globe, + Activity +} from "lucide-react"; +import { Navbar } from "@/components/Navbar"; + +const AVAILABLE_EVENTS = [ + { id: 'course.published', label: 'Course Published', description: 'Triggered when a course is published to LMS' }, + { id: 'lesson.completed', label: 'Lesson Completed', description: 'Triggered when a student completes a lesson' }, + { id: 'user.enrolled', label: 'User Enrolled', description: 'Triggered when a user enrolls in a course' } +]; + +export default function WebhooksPage() { + const { user } = useAuth(); + const [webhooks, setWebhooks] = useState([]); + const [loading, setLoading] = useState(true); + const [isAdding, setIsAdding] = useState(false); + const [newWebhook, setNewWebhook] = useState({ + url: '', + events: ['course.published'], + secret: '' + }); + const [error, setError] = useState(null); + + useEffect(() => { + if (user) { + fetchWebhooks(); + } + }, [user]); + + const fetchWebhooks = async () => { + try { + const data = await cmsApi.getWebhooks(); + setWebhooks(data); + } catch (err: any) { + setError(err.message); + } finally { + setLoading(false); + } + }; + + const handleCreate = async (e: React.FormEvent) => { + e.preventDefault(); + setError(null); + try { + await cmsApi.createWebhook(newWebhook); + setNewWebhook({ url: '', events: ['course.published'], secret: '' }); + setIsAdding(false); + fetchWebhooks(); + } catch (err: any) { + setError(err.message); + } + }; + + const handleDelete = async (id: string) => { + if (!confirm('Are you sure you want to delete this webhook?')) return; + try { + await cmsApi.deleteWebhook(id); + fetchWebhooks(); + } catch (err: any) { + setError(err.message); + } + }; + + const toggleEvent = (eventId: string) => { + setNewWebhook(prev => ({ + ...prev, + events: prev.events.includes(eventId) + ? prev.events.filter(e => e !== eventId) + : [...prev.events, eventId] + })); + }; + + if (loading) return ( +
+
+
+ ); + + return ( +
+ +
+
+
+

+ + Enterprise Webhooks +

+

Integrate OpenCCB with your external systems via HTTP callbacks.

+
+ +
+ + {error && ( +
+ + {error} +
+ )} + + {isAdding && ( +
+
+

+ + Configure New Webhook +

+
+
+
+ + setNewWebhook({ ...newWebhook, url: e.target.value })} + /> +
+
+ + setNewWebhook({ ...newWebhook, secret: e.target.value })} + /> +
+
+ +
+ +
+ {AVAILABLE_EVENTS.map(event => ( +
toggleEvent(event.id)} + className={`p-4 rounded-2xl border transition-all cursor-pointer ${newWebhook.events.includes(event.id) + ? 'bg-blue-500/20 border-blue-500 text-blue-400 shadow-[0_0_15px_rgba(59,130,246,0.1)]' + : 'bg-black/20 border-white/10 text-gray-400 hover:bg-white/5' + }`} + > +
+ {event.label} + {newWebhook.events.includes(event.id) && } +
+

{event.description}

+
+ ))} +
+
+ +
+ + +
+
+
+ )} + +
+ {webhooks.length === 0 && !isAdding ? ( +
+ +

No webhooks configured

+

Add your first webhook to start receiving system notifications.

+
+ ) : ( + webhooks.map(webhook => ( +
+
+
+
+ +
+
+

{webhook.url}

+

Created on {new Date(webhook.created_at).toLocaleDateString()}

+
+
+
+ {webhook.events.map(event => ( + + {event} + + ))} + {webhook.secret && ( + + Signed + + )} +
+
+
+
+ + {webhook.is_active ? 'Active' : 'Paused'} + + ID: {webhook.id.slice(0, 8)}... +
+ +
+
+ )) + )} +
+
+
+ ); +} diff --git a/web/studio/src/components/Navbar.tsx b/web/studio/src/components/Navbar.tsx index d2b6a98..5c3729d 100644 --- a/web/studio/src/components/Navbar.tsx +++ b/web/studio/src/components/Navbar.tsx @@ -2,7 +2,7 @@ import Link from 'next/link'; import { useAuth } from '@/context/AuthContext'; -import { LayoutDashboard, Building2, Users2, LogOut } from 'lucide-react'; +import { LayoutDashboard, Building2, Users2, LogOut, Webhook } from 'lucide-react'; export function Navbar() { const { user, logout } = useAuth(); @@ -42,6 +42,13 @@ export function Navbar() { Users + + + Webhooks + )} diff --git a/web/studio/src/lib/api.ts b/web/studio/src/lib/api.ts index f10eec6..0aa8fa8 100644 --- a/web/studio/src/lib/api.ts +++ b/web/studio/src/lib/api.ts @@ -146,6 +146,40 @@ export interface CourseAnalytics { }[]; } +export interface CohortData { + period: string; + count: number; + completion_rate: number; +} + +export interface RetentionData { + lesson_id: string; + lesson_title: string; + student_count: number; +} + +export interface AdvancedAnalytics { + cohorts: CohortData[]; + retention: RetentionData[]; +} + +export interface Webhook { + id: string; + organization_id: string; + url: string; + events: string[]; + secret?: string; + is_active: boolean; + created_at: string; + updated_at: string; +} + +export interface CreateWebhookPayload { + url: string; + events: string[]; + secret?: string; +} + const getToken = () => typeof window !== 'undefined' ? localStorage.getItem('studio_token') : null; const apiFetch = (url: string, options: RequestInit = {}) => { @@ -212,11 +246,17 @@ export const cmsApi = { // Admin & Analytics getAuditLogs: (): Promise => apiFetch('/audit-logs'), getCourseAnalytics: (id: string): Promise => apiFetch(`/courses/${id}/analytics`), + getAdvancedAnalytics: (id: string): Promise => apiFetch(`/courses/${id}/analytics/advanced`), // Users getAllUsers: (): Promise => apiFetch('/users'), updateUser: (id: string, role: string, organization_id: string): Promise => apiFetch(`/users/${id}`, { method: 'PUT', body: JSON.stringify({ role, organization_id }) }), + // Webhooks + getWebhooks: (): Promise => apiFetch('/webhooks'), + createWebhook: (payload: CreateWebhookPayload): Promise => apiFetch('/webhooks', { method: 'POST', body: JSON.stringify(payload) }), + deleteWebhook: (id: string): Promise => apiFetch(`/webhooks/${id}`, { method: 'DELETE' }), + // Assets uploadAsset: (file: File): Promise => { const formData = new FormData();