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.
This commit is contained in:
2026-01-11 02:34:23 -03:00
parent a19da8de76
commit b1eb23926e
42 changed files with 2661 additions and 588 deletions
Generated
+8
View File
@@ -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",
]
+3
View File
@@ -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"
+210 -137
View File
@@ -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
```
---
## 🏆 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.
+1 -1
View File
@@ -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 ""
+12 -26
View File
@@ -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:
+3 -4
View File
@@ -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 "===================================================="
+4 -5
View File
@@ -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.
+3
View File
@@ -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
-23
View File
@@ -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"]
@@ -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;
@@ -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);
@@ -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 $$;
@@ -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;
+43
View File
@@ -0,0 +1,43 @@
use sqlx::{Postgres, Transaction};
use uuid::Uuid;
pub async fn set_session_context(
tx: &mut Transaction<'_, Postgres>,
user_id: Option<Uuid>,
org_id: Option<Uuid>,
ip_address: Option<String>,
user_agent: Option<String>,
event_type: Option<String>,
) -> 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(())
}
+598 -153
View File
@@ -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<PgPool>,
headers: axum::http::HeaderMap,
Json(payload): Json<serde_json::Value>,
) -> Result<Json<Course>, 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<PgPool>,
headers: axum::http::HeaderMap,
Json(payload): Json<serde_json::Value>,
) -> Result<Json<Module>, 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<PgPool>,
headers: axum::http::HeaderMap,
Json(payload): Json<serde_json::Value>,
) -> Result<Json<Lesson>, 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<PgPool>,
headers: axum::http::HeaderMap,
Path(id): Path<Uuid>,
Json(payload): Json<serde_json::Value>,
) -> Result<Json<Lesson>, 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::<DateTime<Utc>>().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::<DateTime<Utc>>().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<ReorderItem>,
}
#[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<PgPool>,
headers: axum::http::HeaderMap,
Json(payload): Json<ReorderPayload>,
) -> Result<StatusCode, StatusCode> {
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<PgPool>,
headers: axum::http::HeaderMap,
Json(payload): Json<ReorderPayload>,
) -> Result<StatusCode, StatusCode> {
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<PgPool>,
Json(payload): Json<AuthPayload>,
) -> Result<Json<AuthResponse>, (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<PgPool>,
Path(id): Path<Uuid>,
) -> Result<Json<common::models::AdvancedAnalytics>, (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::<common::models::AdvancedAnalytics>()
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
Ok(Json(analytics))
}
#[derive(Deserialize)]
pub struct AuditQuery {
pub page: Option<i64>,
@@ -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<PgPool>,
headers: axum::http::HeaderMap,
Path(id): Path<Uuid>,
Json(payload): Json<serde_json::Value>,
) -> Result<Json<Module>, 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<PgPool>,
headers: axum::http::HeaderMap,
Path(id): Path<Uuid>,
) -> Result<StatusCode, StatusCode> {
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<PgPool>,
headers: axum::http::HeaderMap,
Path(id): Path<Uuid>,
) -> Result<StatusCode, StatusCode> {
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<String>,
pub secret: Option<String>,
}
pub async fn get_webhooks(
Org(org_ctx): Org,
claims: common::auth::Claims,
State(pool): State<PgPool>,
) -> Result<Json<Vec<common::models::Webhook>>, (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<PgPool>,
Json(payload): Json<CreateWebhookPayload>,
) -> Result<Json<common::models::Webhook>, (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<PgPool>,
Path(id): Path<Uuid>,
) -> Result<StatusCode, (StatusCode, String)> {
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)
}
+15 -1
View File
@@ -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",
+93
View File
@@ -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<Sha256>;
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())
}
-23
View File
@@ -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"]
@@ -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;
@@ -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);
+40
View File
@@ -0,0 +1,40 @@
use sqlx::{Postgres, Transaction};
pub async fn set_session_context(
tx: &mut Transaction<'_, Postgres>,
user_id: Option<uuid::Uuid>,
org_id: Option<uuid::Uuid>,
ip: Option<String>,
ua: Option<String>,
event_type: Option<String>,
) -> 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(())
}
+210 -75
View File
@@ -15,9 +15,10 @@ use sqlx::{PgPool, Row};
use uuid::Uuid;
pub async fn enroll_user(
State(pool): State<PgPool>,
Org(org_ctx): Org,
claims: Claims,
State(pool): State<PgPool>,
headers: axum::http::HeaderMap,
Json(payload): Json<serde_json::Value>,
) -> Result<Json<Enrollment>, 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<PgPool>,
headers: axum::http::HeaderMap,
Json(payload): Json<GradeSubmissionPayload>,
) -> Result<Json<common::models::UserGrade>, (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<Option<i32>> = 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<BadgeResponse>,
}
@@ -528,12 +594,11 @@ pub async fn get_user_gamification(
State(pool): State<PgPool>,
Path(user_id): Path<Uuid>,
) -> Result<Json<GamificationStatus>, 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<PgPool>,
) -> Result<Json<Vec<UserResponse>>, 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<PgPool>,
Path(course_id): Path<Uuid>,
) -> Result<Json<common::models::AdvancedAnalytics>, 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,
}))
}
+25 -9
View File
@@ -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))
+5
View File
@@ -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
+3 -2
View File
@@ -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;
+30
View File
@@ -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<CohortData>,
pub retention: Vec<RetentionData>,
}
#[derive(Debug, Serialize, Deserialize, sqlx::FromRow, Clone)]
pub struct Webhook {
pub id: Uuid,
pub organization_id: Uuid,
pub url: String,
pub events: Vec<String>,
pub secret: Option<String>,
pub is_active: bool,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
#[cfg(test)]
mod tests {
+84
View File
@@ -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<Sha256>;
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())
}
+2 -2
View File
@@ -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 \
+29 -12
View File
@@ -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"]
-36
View File
@@ -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.
+7
View File
@@ -0,0 +1,7 @@
#!/bin/sh
# Start the LMS backend in the background
./lms-service &
# Start the Experience frontend
node server.js
+65 -27
View File
@@ -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<Course[]>([]);
const [enrollments, setEnrollments] = useState<string[]>([]);
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() {
</div>
{user && gamification && (
<div className="mb-16 grid grid-cols-1 md:grid-cols-3 gap-6 animate-in fade-in slide-in-from-top-6 duration-700">
<div className="md:col-span-1 glass-card p-8 bg-gradient-to-br from-blue-600/20 to-indigo-700/20 border-blue-500/20 flex flex-col items-center justify-center text-center rounded-3xl">
<div className="w-16 h-16 rounded-full bg-blue-500/10 flex items-center justify-center mb-4 border border-blue-500/20">
<Star className="text-blue-400 fill-blue-400/20" size={32} />
<div className="mb-16 grid grid-cols-1 lg:grid-cols-3 gap-8 animate-in fade-in slide-in-from-top-6 duration-700">
<div className="lg:col-span-2 space-y-8">
<div className="glass-card p-10 bg-gradient-to-br from-blue-600/20 via-indigo-700/10 to-transparent border-blue-500/20 rounded-3xl relative overflow-hidden group">
<div className="relative z-10 flex flex-col md:flex-row md:items-center gap-10">
<div className="flex-shrink-0 relative">
<div className="w-24 h-24 rounded-3xl bg-blue-600 flex items-center justify-center shadow-2xl shadow-blue-500/40 rotate-3 group-hover:rotate-0 transition-transform duration-500">
<Zap className="text-white fill-white/20" size={48} />
</div>
<div className="absolute -bottom-2 -right-2 w-10 h-10 rounded-full bg-white text-black flex items-center justify-center font-black text-xs border-4 border-[#050505]">
{gamification.level}
</div>
</div>
<div className="flex-1 space-y-4">
<div>
<div className="text-[10px] font-black uppercase tracking-[0.3em] text-blue-400 mb-1">Current Standing</div>
<h2 className="text-3xl font-black text-white">Level {gamification.level} Pioneer</h2>
</div>
<div className="space-y-2">
<div className="flex justify-between items-end">
<div className="text-[10px] font-bold text-gray-500 uppercase tracking-widest">
{gamification.points} / {Math.pow(gamification.level, 2) * 100} XP
</div>
<div className="text-[10px] font-black text-blue-400 uppercase tracking-widest">
{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}
</div>
</div>
<div className="h-2 w-full bg-white/5 rounded-full overflow-hidden border border-white/5">
<div
className="h-full bg-gradient-to-r from-blue-500 to-indigo-500 shadow-[0_0_20px_rgba(59,130,246,0.5)] transition-all duration-1000"
style={{ width: `${Math.min(100, Math.max(0, ((gamification.points - Math.pow(gamification.level - 1, 2) * 100) / (Math.pow(gamification.level, 2) * 100 - Math.pow(gamification.level - 1, 2) * 100)) * 100))}%` }}
></div>
</div>
</div>
</div>
</div>
{/* Background Flair */}
<div className="absolute -bottom-20 -right-20 w-64 h-64 bg-blue-500/10 blur-[120px] rounded-full pointer-events-none group-hover:bg-blue-500/20 transition-colors duration-500"></div>
</div>
<div className="glass-card p-8 bg-white/[0.01] border-white/5 rounded-3xl">
<h3 className="text-xs font-black uppercase tracking-widest text-gray-500 mb-6 flex items-center gap-2">
<CheckCircle2 size={14} /> My Badges
</h3>
<div className="flex flex-wrap gap-4">
{gamification.badges.length === 0 ? (
<p className="text-sm text-gray-600 italic">No badges earned yet. Start learning to unlock achievements!</p>
) : (
gamification.badges.map(badge => (
<div key={badge.id} className="group/badge relative">
<div className="w-14 h-14 rounded-2xl bg-gradient-to-tr from-amber-400/10 to-orange-500/10 border border-amber-500/20 flex items-center justify-center shadow-lg transition-all hover:scale-110 hover:bg-amber-500/20 cursor-help" title={badge.description}>
<span className="text-2xl">🏆</span>
</div>
<div className="absolute -bottom-1 -right-1 w-5 h-5 bg-green-500 rounded-full border-2 border-[#050505] flex items-center justify-center text-[8px] font-bold"></div>
</div>
))
)}
</div>
</div>
<div className="text-4xl font-black text-white mb-1">{gamification.points}</div>
<div className="text-[10px] font-black uppercase tracking-[0.2em] text-blue-400">Total Experience Points</div>
</div>
<div className="md:col-span-2 glass-card p-8 bg-white/[0.01] border-white/5 rounded-3xl overflow-hidden relative">
<h3 className="text-xs font-black uppercase tracking-widest text-gray-500 mb-6 flex items-center gap-2">
<CheckCircle2 size={14} /> My Badges
</h3>
<div className="flex flex-wrap gap-4">
{gamification.badges.length === 0 ? (
<p className="text-sm text-gray-600 italic">No badges earned yet. Start learning to unlock achievements!</p>
) : (
gamification.badges.map(badge => (
<div key={badge.id} className="group/badge relative">
<div className="w-12 h-12 rounded-xl bg-gradient-to-tr from-amber-400/20 to-orange-500/20 border border-amber-500/30 flex items-center justify-center shadow-lg transition-transform hover:scale-110 cursor-help" title={badge.description}>
<span className="text-xl">🏆</span>
</div>
<div className="absolute -bottom-1 -right-1 w-4 h-4 bg-green-500 rounded-full border-2 border-black flex items-center justify-center text-[8px] font-bold"></div>
</div>
))
)}
</div>
{/* Visual Flair */}
<div className="lg:col-span-1">
<Leaderboard />
</div>
</div>
)}
@@ -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<User[]>([]);
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 <div className="animate-pulse space-y-4">
{[1, 2, 3].map(i => (
<div key={i} className="h-16 bg-white/5 rounded-2xl w-full"></div>
))}
</div>;
}
return (
<div className="glass-card p-8 border-white/5 bg-white/[0.01] rounded-3xl overflow-hidden relative">
<h3 className="text-xs font-black uppercase tracking-widest text-gray-500 mb-6 flex items-center gap-2">
<Trophy size={14} className="text-amber-500" /> Leaderboard
</h3>
<div className="space-y-3">
{topUsers.map((user, index) => (
<div
key={user.id}
className={`flex items-center gap-4 p-4 rounded-2xl transition-all ${index === 0 ? 'bg-gradient-to-r from-amber-500/10 to-transparent border border-amber-500/20 shadow-lg shadow-amber-500/5' :
'bg-white/5 border border-white/5 hover:bg-white/10'
}`}
>
<div className="flex-shrink-0 w-8 text-center font-black text-xs text-gray-600">
{index === 0 ? <Medal className="text-amber-500 mx-auto" size={18} /> :
index === 1 ? <Medal className="text-gray-400 mx-auto" size={18} /> :
index === 2 ? <Medal className="text-amber-700 mx-auto" size={18} /> :
index + 1}
</div>
<div className="flex-1">
<div className="text-sm font-bold text-gray-200 line-clamp-1">{user.full_name}</div>
<div className="text-[10px] font-bold text-gray-500 uppercase tracking-widest">Level {user.level || 1}</div>
</div>
<div className="text-right">
<div className="text-sm font-black text-white">{user.xp || 0}</div>
<div className="text-[8px] font-black text-blue-500 uppercase tracking-widest">XP</div>
</div>
</div>
))}
{topUsers.length === 0 && (
<div className="text-center py-8 text-gray-600 italic text-sm">
No ranking data available yet.
</div>
)}
</div>
{/* Visual background flair */}
<div className="absolute -top-24 -right-24 w-48 h-48 bg-amber-500/10 blur-[100px] rounded-full pointer-events-none"></div>
</div>
);
}
+12 -1
View File
@@ -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<User[]> {
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<Organization> {
const response = await fetch(`${CMS_API_URL}/organizations/${orgId}/branding`);
if (!response.ok) throw new Error('Failed to fetch branding');
+29 -11
View File
@@ -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"]
-36
View File
@@ -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.
+7
View File
@@ -0,0 +1,7 @@
#!/bin/sh
# Start the CMS backend in the background
./cms-service &
# Start the Studio frontend
node server.js
@@ -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<Course | null>(null);
const [analytics, setAnalytics] = useState<AdvancedAnalytics | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(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 (
<div className="min-h-screen bg-gray-900 flex items-center justify-center">
<div className="w-12 h-12 border-4 border-blue-500/20 border-t-blue-500 rounded-full animate-spin"></div>
</div>
);
if (error || !course || !analytics) return (
<div className="min-h-screen bg-[#0f1115] text-white p-20 text-center flex flex-col items-center justify-center gap-6">
<div className="text-gray-400">{error || "Data unavailable"}</div>
<button onClick={() => router.back()} className="btn-premium px-8">Go Back</button>
</div>
);
return (
<div className="min-h-screen bg-[#0f1115] text-white p-8">
<div className="max-w-7xl mx-auto">
{/* Header */}
<div className="flex items-center justify-between mb-12">
<div className="flex items-center gap-4">
<button
onClick={() => router.push(`/courses/${id}/analytics`)}
className="p-2 hover:bg-white/10 rounded-full transition-colors"
>
<ArrowLeft className="w-6 h-6" />
</button>
<div>
<h1 className="text-3xl font-bold bg-gradient-to-r from-purple-400 to-indigo-400 bg-clip-text text-transparent">
Advanced Insights
</h1>
<p className="text-gray-400 mt-1">Cohort analysis and student retention for {course.title}</p>
</div>
</div>
</div>
<CourseEditorLayout activeTab="analytics">
<div className="p-8 space-y-12">
{/* Cohort Analysis */}
<section>
<div className="flex items-center justify-between mb-8">
<h2 className="text-2xl font-black flex items-center gap-3">
<Layers className="text-purple-500" />
Cohort Completion
</h2>
<div className="flex items-center gap-2 text-xs font-bold text-gray-500 uppercase tracking-widest bg-white/5 px-4 py-2 rounded-xl">
<Filter size={14} /> Grouped by Month
</div>
</div>
<div className="overflow-hidden rounded-3xl border border-white/10 bg-white/[0.02]">
<table className="w-full text-left border-collapse">
<thead>
<tr className="bg-white/5">
<th className="p-6 text-xs font-black uppercase tracking-widest text-gray-500">Cohort (Enrollment Month)</th>
<th className="p-6 text-xs font-black uppercase tracking-widest text-gray-500">Students</th>
<th className="p-6 text-xs font-black uppercase tracking-widest text-gray-500">Avg. Completion Rate</th>
<th className="p-6 text-xs font-black uppercase tracking-widest text-gray-500">Engagement Status</th>
</tr>
</thead>
<tbody className="divide-y divide-white/5">
{analytics.cohorts.length === 0 ? (
<tr>
<td colSpan={4} className="p-12 text-center text-gray-600 italic">No cohort data available yet.</td>
</tr>
) : analytics.cohorts.map((cohort) => (
<tr key={cohort.period} className="hover:bg-white/[0.02] transition-colors">
<td className="p-6 font-bold flex items-center gap-3">
<Calendar size={16} className="text-purple-400" />
{cohort.period}
</td>
<td className="p-6 font-black">{cohort.count}</td>
<td className="p-6">
<div className="flex items-center gap-4">
<div className="flex-1 h-2 bg-white/5 rounded-full overflow-hidden min-w-[200px]">
<div
className="h-full bg-purple-500 transition-all duration-1000 shadow-[0_0_10px_rgba(168,85,247,0.5)]"
style={{ width: `${cohort.completion_rate * 100}%` }}
/>
</div>
<span className="text-sm font-black">{Math.round(cohort.completion_rate * 100)}%</span>
</div>
</td>
<td className="p-6">
{cohort.completion_rate > 0.8 ? (
<span className="text-[10px] font-black uppercase tracking-widest text-green-400 bg-green-400/10 px-3 py-1 rounded-full border border-green-500/20">Excellent</span>
) : cohort.completion_rate > 0.5 ? (
<span className="text-[10px] font-black uppercase tracking-widest text-blue-400 bg-blue-400/10 px-3 py-1 rounded-full border border-blue-500/20">Healthy</span>
) : (
<span className="text-[10px] font-black uppercase tracking-widest text-orange-400 bg-orange-400/10 px-3 py-1 rounded-full border border-orange-500/20">Low Momentum</span>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
</section>
{/* Retention Analysis */}
<section>
<h2 className="text-2xl font-black mb-8 flex items-center gap-3">
<TrendingUp className="text-indigo-500" />
Retention Heatmap
</h2>
<div className="space-y-6">
{analytics.retention.map((item, index) => {
const firstStudentCount = analytics.retention[0]?.student_count || 1;
const percentage = (item.student_count / firstStudentCount) * 100;
return (
<div key={item.lesson_id} className="group relative">
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-4">
<div className="w-8 h-8 rounded-lg bg-white/5 flex items-center justify-center text-[10px] font-black text-gray-500">
{index + 1}
</div>
<span className="font-bold text-gray-300">{item.lesson_title}</span>
</div>
<div className="text-right">
<div className="text-sm font-black text-white">{item.student_count} Students</div>
<div className="text-[10px] font-bold text-gray-600 uppercase tracking-widest">{Math.round(percentage)}% Retention</div>
</div>
</div>
<div className="h-4 w-full bg-white/5 rounded-lg overflow-hidden border border-white/5">
<div
className={`h-full transition-all duration-1000 ${percentage > 80 ? 'bg-indigo-500' :
percentage > 50 ? 'bg-indigo-600/70' :
'bg-indigo-700/40'
}`}
style={{ width: `${percentage}%` }}
/>
</div>
{index > 0 && analytics.retention[index - 1].student_count > 0 && (
<div className="absolute -top-4 right-0 text-[10px] font-black text-red-500/50">
-{Math.round(100 - (item.student_count / analytics.retention[index - 1].student_count) * 100)}% drop
</div>
)}
</div>
);
})}
</div>
</section>
</div>
</CourseEditorLayout>
</div>
</div>
);
}
@@ -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() {
<p className="text-gray-400 mt-1">Performance insights and student progress for {course?.title}</p>
</div>
</div>
<div className="bg-blue-500/20 text-blue-400 text-[10px] font-black uppercase tracking-wider px-2 py-1 rounded border border-blue-500/30">
{user?.role} View
<div className="flex items-center gap-4">
<button
onClick={() => router.push(`/courses/${id}/analytics/advanced`)}
className="btn-premium !bg-purple-600/10 !text-purple-400 border border-purple-500/20 hover:!bg-purple-600/20 !shadow-none gap-2 text-xs py-2"
>
<Layers size={14} /> Advanced Insights
</button>
<div className="bg-blue-500/20 text-blue-400 text-[10px] font-black uppercase tracking-wider px-2 py-1 rounded border border-blue-500/30">
{user?.role} View
</div>
</div>
</div>
@@ -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<Webhook[]>([]);
const [loading, setLoading] = useState(true);
const [isAdding, setIsAdding] = useState(false);
const [newWebhook, setNewWebhook] = useState<CreateWebhookPayload>({
url: '',
events: ['course.published'],
secret: ''
});
const [error, setError] = useState<string | null>(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 (
<div className="min-h-screen bg-gray-900 flex items-center justify-center">
<div className="w-12 h-12 border-4 border-blue-500/20 border-t-blue-500 rounded-full animate-spin"></div>
</div>
);
return (
<div className="min-h-screen bg-[#0f1115] text-white">
<Navbar />
<main className="max-w-5xl mx-auto pt-32 pb-20 px-6">
<div className="flex items-center justify-between mb-12">
<div>
<h1 className="text-4xl font-black mb-2 flex items-center gap-4">
<WebhookIcon size={40} className="text-blue-500" />
Enterprise Webhooks
</h1>
<p className="text-gray-400">Integrate OpenCCB with your external systems via HTTP callbacks.</p>
</div>
<button
onClick={() => setIsAdding(true)}
className="btn-premium flex items-center gap-2 px-6 py-3"
>
<Plus size={20} /> Add Webhook
</button>
</div>
{error && (
<div className="mb-8 p-4 bg-red-500/10 border border-red-500/20 rounded-2xl flex items-center gap-3 text-red-400" id="webhook-error">
<AlertCircle size={20} />
<span className="text-sm font-bold">{error}</span>
</div>
)}
{isAdding && (
<div className="mb-12 bg-white/5 border border-white/10 rounded-3xl p-8 overflow-hidden relative group">
<div className="absolute inset-0 bg-blue-500/5 opacity-0 group-hover:opacity-100 transition-opacity pointer-events-none" />
<h2 className="text-xl font-black mb-6 flex items-center gap-2">
<Plus size={20} className="text-blue-400" />
Configure New Webhook
</h2>
<form onSubmit={handleCreate} className="space-y-6 relative">
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="space-y-2">
<label className="text-xs font-black text-gray-400 uppercase tracking-widest flex items-center gap-2">
<Globe size={14} /> Payload URL
</label>
<input
type="url"
id="webhook-url"
required
placeholder="https://your-api.com/webhooks"
className="w-full bg-black/40 border border-white/10 rounded-xl px-4 py-3 focus:outline-none focus:border-blue-500 transition-colors"
value={newWebhook.url}
onChange={e => setNewWebhook({ ...newWebhook, url: e.target.value })}
/>
</div>
<div className="space-y-2">
<label className="text-xs font-black text-gray-400 uppercase tracking-widest flex items-center gap-2">
<Shield size={14} /> Secret (HMAC-SHA256)
</label>
<input
type="text"
id="webhook-secret"
placeholder="Optional signing secret"
className="w-full bg-black/40 border border-white/10 rounded-xl px-4 py-3 focus:outline-none focus:border-blue-500 transition-colors"
value={newWebhook.secret}
onChange={e => setNewWebhook({ ...newWebhook, secret: e.target.value })}
/>
</div>
</div>
<div className="space-y-4">
<label className="text-xs font-black text-gray-400 uppercase tracking-widest flex items-center gap-2">
<Activity size={14} /> Events to Subscribe
</label>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{AVAILABLE_EVENTS.map(event => (
<div
key={event.id}
id={`event-${event.id}`}
onClick={() => 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'
}`}
>
<div className="flex items-center justify-between mb-1">
<span className="font-bold">{event.label}</span>
{newWebhook.events.includes(event.id) && <CheckCircle2 size={16} />}
</div>
<p className="text-[10px] opacity-60 leading-relaxed font-medium">{event.description}</p>
</div>
))}
</div>
</div>
<div className="flex items-center justify-end gap-4 pt-4 border-t border-white/10">
<button
type="button"
onClick={() => setIsAdding(false)}
className="px-6 py-2 text-sm font-bold text-gray-400 hover:text-white transition-colors"
>
Cancel
</button>
<button type="submit" id="create-webhook-btn" className="btn-premium px-8 py-2">Create Webhook</button>
</div>
</form>
</div>
)}
<div className="space-y-6">
{webhooks.length === 0 && !isAdding ? (
<div className="text-center py-20 bg-white/5 border border-dashed border-white/10 rounded-3xl">
<WebhookIcon size={64} className="mx-auto text-gray-600 mb-6" />
<h3 className="text-xl font-bold text-gray-400">No webhooks configured</h3>
<p className="text-sm text-gray-500 mt-2">Add your first webhook to start receiving system notifications.</p>
</div>
) : (
webhooks.map(webhook => (
<div key={webhook.id} className="bg-white/5 border border-white/10 rounded-3xl p-8 flex items-center justify-between group hover:bg-white/[0.07] transition-all">
<div className="space-y-3">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-xl bg-blue-500/10 flex items-center justify-center text-blue-400">
<Globe size={20} />
</div>
<div>
<h3 className="font-bold text-lg">{webhook.url}</h3>
<p className="text-xs text-gray-500">Created on {new Date(webhook.created_at).toLocaleDateString()}</p>
</div>
</div>
<div className="flex flex-wrap gap-2">
{webhook.events.map(event => (
<span key={event} className="text-[10px] font-black uppercase tracking-widest bg-blue-500/10 text-blue-400 px-3 py-1 rounded-full border border-blue-500/20">
{event}
</span>
))}
{webhook.secret && (
<span className="text-[10px] font-black uppercase tracking-widest bg-purple-500/10 text-purple-400 px-3 py-1 rounded-full border border-purple-500/20 flex items-center gap-1">
<Shield size={10} /> Signed
</span>
)}
</div>
</div>
<div className="flex items-center gap-4">
<div className="flex flex-col items-end mr-4">
<span className={`text-[10px] font-black uppercase tracking-widest ${webhook.is_active ? 'text-green-400' : 'text-gray-500'}`}>
{webhook.is_active ? 'Active' : 'Paused'}
</span>
<span className="text-[10px] font-bold text-gray-600">ID: {webhook.id.slice(0, 8)}...</span>
</div>
<button
onClick={() => handleDelete(webhook.id)}
className="delete-webhook-btn p-3 bg-red-500/10 text-red-400 rounded-2xl opacity-0 group-hover:opacity-100 hover:bg-red-500 hover:text-white transition-all transform hover:scale-110 shadow-lg"
title="Delete Webhook"
>
<Trash2 size={20} />
</button>
</div>
</div>
))
)}
</div>
</main>
</div>
);
}
+8 -1
View File
@@ -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() {
<Users2 className="w-4 h-4" />
Users
</Link>
<Link
href="/settings/webhooks"
className="text-sm font-medium text-gray-400 hover:text-blue-400 transition-colors flex items-center gap-2"
>
<Webhook className="w-4 h-4" />
Webhooks
</Link>
</>
)}
+40
View File
@@ -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<AuditLog[]> => apiFetch('/audit-logs'),
getCourseAnalytics: (id: string): Promise<CourseAnalytics> => apiFetch(`/courses/${id}/analytics`),
getAdvancedAnalytics: (id: string): Promise<AdvancedAnalytics> => apiFetch(`/courses/${id}/analytics/advanced`),
// Users
getAllUsers: (): Promise<User[]> => apiFetch('/users'),
updateUser: (id: string, role: string, organization_id: string): Promise<void> => apiFetch(`/users/${id}`, { method: 'PUT', body: JSON.stringify({ role, organization_id }) }),
// Webhooks
getWebhooks: (): Promise<Webhook[]> => apiFetch('/webhooks'),
createWebhook: (payload: CreateWebhookPayload): Promise<Webhook> => apiFetch('/webhooks', { method: 'POST', body: JSON.stringify(payload) }),
deleteWebhook: (id: string): Promise<void> => apiFetch(`/webhooks/${id}`, { method: 'DELETE' }),
// Assets
uploadAsset: (file: File): Promise<UploadResponse> => {
const formData = new FormData();