feat: Implement multi-tenancy with organization ID in LMS tables and middleware, refactor web API calls, and update analytics and gamification features."
This commit is contained in:
@@ -246,17 +246,21 @@ curl -X POST "http://localhost:8000/chat" \
|
|||||||
#### GET /courses/{id}/analytics/advanced
|
#### GET /courses/{id}/analytics/advanced
|
||||||
Métricas de retención y análisis de cohortes.
|
Métricas de retención y análisis de cohortes.
|
||||||
|
|
||||||
- **Respuesta ( AdvancedAnalyticsResponse ):**
|
---
|
||||||
```json
|
|
||||||
{
|
### 5. Multi-tenencia y Gestión (Solo Admin)
|
||||||
"cohorts": [
|
OpenCCB permite gestionar múltiples organizaciones desde un único punto de acceso.
|
||||||
{ "period": "string", "count": "int", "completion_rate": "float" }
|
|
||||||
],
|
#### X-Organization-Id Header
|
||||||
"retention": [
|
Los administradores pueden simular el contexto de cualquier organización enviando este encabezado:
|
||||||
{ "lesson_id": "uuid", "lesson_title": "string", "student_count": "int" }
|
```bash
|
||||||
]
|
curl -H "Authorization: Bearer $TOKEN" \
|
||||||
}
|
-H "X-Organization-Id: $ORG_ID" \
|
||||||
```
|
http://localhost:3001/courses
|
||||||
|
```
|
||||||
|
|
||||||
|
#### GET /organizations
|
||||||
|
Lista todas las organizaciones registradas.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -265,6 +269,7 @@ OpenCCB incluye un sistema integrado de:
|
|||||||
- **XP y Niveles**: Los estudiantes progresan al completar lecciones.
|
- **XP y Niveles**: Los estudiantes progresan al completar lecciones.
|
||||||
- **Leaderboards**: Rankings dentro de la organización.
|
- **Leaderboards**: Rankings dentro de la organización.
|
||||||
- **Analíticas Avanzadas**: Análisis de cohortes y mapas de calor de retención para instructores.
|
- **Analíticas Avanzadas**: Análisis de cohortes y mapas de calor de retención para instructores.
|
||||||
|
- **Multi-tenencia Nativa**: Aislamiento total de datos entre organizaciones.
|
||||||
|
|
||||||
## 📄 Licencia
|
## 📄 Licencia
|
||||||
Este proyecto es código abierto y está disponible bajo los términos de la licencia especificada en el repositorio.
|
Este proyecto es código abierto y está disponible bajo los términos de la licencia especificada en el repositorio.
|
||||||
@@ -135,6 +135,9 @@ if ! grep -q "DATABASE_URL=" .env || [[ $(grep "DATABASE_URL=" .env | cut -d'='
|
|||||||
update_env "DATABASE_URL" "postgresql://user:${DB_PASS}@localhost:5432/openccb"
|
update_env "DATABASE_URL" "postgresql://user:${DB_PASS}@localhost:5432/openccb"
|
||||||
update_env "CMS_DATABASE_URL" "postgresql://user:${DB_PASS}@localhost:5432/openccb_cms"
|
update_env "CMS_DATABASE_URL" "postgresql://user:${DB_PASS}@localhost:5432/openccb_cms"
|
||||||
update_env "LMS_DATABASE_URL" "postgresql://user:${DB_PASS}@localhost:5432/openccb_lms"
|
update_env "LMS_DATABASE_URL" "postgresql://user:${DB_PASS}@localhost:5432/openccb_lms"
|
||||||
|
update_env "JWT_SECRET" "supersecretsecret"
|
||||||
|
update_env "NEXT_PUBLIC_CMS_API_URL" "http://localhost:3001"
|
||||||
|
update_env "NEXT_PUBLIC_LMS_API_URL" "http://localhost:3002"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# 5. AI Stack Setup
|
# 5. AI Stack Setup
|
||||||
|
|||||||
+6
-6
@@ -81,19 +81,19 @@
|
|||||||
- [x] Custom color schemes (Primary/Secondary)
|
- [x] Custom color schemes (Primary/Secondary)
|
||||||
- [x] Dynamic Experience Portal adaptation
|
- [x] Dynamic Experience Portal adaptation
|
||||||
- [x] Live Branding Preview in Studio
|
- [x] Live Branding Preview in Studio
|
||||||
- [ ] **Advanced Analytics**:
|
- [x] **Advanced Analytics**:
|
||||||
- [ ] Cohort analysis
|
- [x] Cohort analysis (Implemented)
|
||||||
- [ ] Retention metrics
|
- [x] Retention metrics (Implemented)
|
||||||
- [ ] Engagement heatmaps
|
- [ ] Engagement heatmaps
|
||||||
- [ ] **AI Integration** (Next Up):
|
- [ ] **AI Integration** (Next Up):
|
||||||
- [x] AI-driven lesson summaries (Implemented)
|
- [x] AI-driven lesson summaries (Implemented)
|
||||||
- [ ] Implement real-time video transcription via external API
|
- [ ] Implement real-time video transcription via external API
|
||||||
- [x] Automated quiz generation (Implemented)
|
- [x] Automated quiz generation (Implemented)
|
||||||
- [ ] Personalized learning paths
|
- [ ] Personalized learning paths
|
||||||
- [x] **Gamification**: (Base system implemented)
|
- [x] **Gamification**: (Broadly implemented)
|
||||||
- [x] Badges and achievements (Implemented base system)
|
- [x] Badges and achievements (Implemented base system)
|
||||||
- [ ] Leaderboards
|
- [x] Leaderboards (Implemented)
|
||||||
- [x] XP and leveling system
|
- [x] XP and leveling system (Implemented)
|
||||||
- [x] **Course Management Enhancements**:
|
- [x] **Course Management Enhancements**:
|
||||||
- [x] Manual naming for modules, lessons, and activities during creation.
|
- [x] Manual naming for modules, lessons, and activities during creation.
|
||||||
- [x] Reordering for modules, lessons, and activities (Level up/down).
|
- [x] Reordering for modules, lessons, and activities (Level up/down).
|
||||||
|
|||||||
@@ -88,7 +88,7 @@ pub async fn publish_course(
|
|||||||
return Err(StatusCode::INTERNAL_SERVER_ERROR);
|
return Err(StatusCode::INTERNAL_SERVER_ERROR);
|
||||||
}
|
}
|
||||||
|
|
||||||
log_action(&pool, Uuid::new_v4(), "PUBLISH", "Course", id, json!({})).await;
|
log_action(&pool, org_ctx.id, Uuid::new_v4(), "PUBLISH", "Course", id, json!({})).await;
|
||||||
|
|
||||||
// 5. Trigger Webhook
|
// 5. Trigger Webhook
|
||||||
let webhook_service = WebhookService::new(pool.clone());
|
let webhook_service = WebhookService::new(pool.clone());
|
||||||
@@ -484,13 +484,15 @@ pub async fn create_lesson(
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub async fn process_transcription(
|
pub async fn process_transcription(
|
||||||
|
Org(org_ctx): Org,
|
||||||
claims: common::auth::Claims,
|
claims: common::auth::Claims,
|
||||||
State(pool): State<PgPool>,
|
State(pool): State<PgPool>,
|
||||||
Path(id): Path<Uuid>,
|
Path(id): Path<Uuid>,
|
||||||
) -> Result<Json<Lesson>, StatusCode> {
|
) -> Result<Json<Lesson>, StatusCode> {
|
||||||
// 1. Fetch lesson
|
// 1. Fetch lesson
|
||||||
let lesson = sqlx::query_as::<_, Lesson>("SELECT * FROM lessons WHERE id = $1")
|
let lesson = sqlx::query_as::<_, Lesson>("SELECT * FROM lessons WHERE id = $1 AND organization_id = $2")
|
||||||
.bind(id)
|
.bind(id)
|
||||||
|
.bind(org_ctx.id)
|
||||||
.fetch_one(&pool)
|
.fetch_one(&pool)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| {
|
.map_err(|e| {
|
||||||
@@ -600,6 +602,7 @@ pub async fn process_transcription(
|
|||||||
|
|
||||||
log_action(
|
log_action(
|
||||||
&pool,
|
&pool,
|
||||||
|
org_ctx.id,
|
||||||
claims.sub,
|
claims.sub,
|
||||||
"TRANSCRIPTION_PROCESSED",
|
"TRANSCRIPTION_PROCESSED",
|
||||||
"Lesson",
|
"Lesson",
|
||||||
@@ -612,13 +615,15 @@ pub async fn process_transcription(
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub async fn summarize_lesson(
|
pub async fn summarize_lesson(
|
||||||
|
Org(org_ctx): Org,
|
||||||
claims: common::auth::Claims,
|
claims: common::auth::Claims,
|
||||||
State(pool): State<PgPool>,
|
State(pool): State<PgPool>,
|
||||||
Path(id): Path<Uuid>,
|
Path(id): Path<Uuid>,
|
||||||
) -> Result<Json<Lesson>, StatusCode> {
|
) -> Result<Json<Lesson>, StatusCode> {
|
||||||
// 1. Fetch lesson
|
// 1. Fetch lesson
|
||||||
let lesson = sqlx::query_as::<_, Lesson>("SELECT * FROM lessons WHERE id = $1")
|
let lesson = sqlx::query_as::<_, Lesson>("SELECT * FROM lessons WHERE id = $1 AND organization_id = $2")
|
||||||
.bind(id)
|
.bind(id)
|
||||||
|
.bind(org_ctx.id)
|
||||||
.fetch_one(&pool)
|
.fetch_one(&pool)
|
||||||
.await
|
.await
|
||||||
.map_err(|_| StatusCode::NOT_FOUND)?;
|
.map_err(|_| StatusCode::NOT_FOUND)?;
|
||||||
@@ -708,6 +713,7 @@ pub async fn summarize_lesson(
|
|||||||
|
|
||||||
log_action(
|
log_action(
|
||||||
&pool,
|
&pool,
|
||||||
|
org_ctx.id,
|
||||||
claims.sub,
|
claims.sub,
|
||||||
"SUMMARY_GENERATED",
|
"SUMMARY_GENERATED",
|
||||||
"Lesson",
|
"Lesson",
|
||||||
@@ -720,13 +726,15 @@ pub async fn summarize_lesson(
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub async fn generate_quiz(
|
pub async fn generate_quiz(
|
||||||
|
Org(org_ctx): Org,
|
||||||
claims: common::auth::Claims,
|
claims: common::auth::Claims,
|
||||||
State(pool): State<PgPool>,
|
State(pool): State<PgPool>,
|
||||||
Path(id): Path<Uuid>,
|
Path(id): Path<Uuid>,
|
||||||
) -> Result<Json<serde_json::Value>, StatusCode> {
|
) -> Result<Json<serde_json::Value>, StatusCode> {
|
||||||
// 1. Fetch lesson
|
// 1. Fetch lesson
|
||||||
let lesson = sqlx::query_as::<_, Lesson>("SELECT * FROM lessons WHERE id = $1")
|
let lesson = sqlx::query_as::<_, Lesson>("SELECT * FROM lessons WHERE id = $1 AND organization_id = $2")
|
||||||
.bind(id)
|
.bind(id)
|
||||||
|
.bind(org_ctx.id)
|
||||||
.fetch_one(&pool)
|
.fetch_one(&pool)
|
||||||
.await
|
.await
|
||||||
.map_err(|_| StatusCode::NOT_FOUND)?;
|
.map_err(|_| StatusCode::NOT_FOUND)?;
|
||||||
@@ -816,7 +824,16 @@ pub async fn generate_quiz(
|
|||||||
.cloned()
|
.cloned()
|
||||||
.unwrap_or(json!([]));
|
.unwrap_or(json!([]));
|
||||||
|
|
||||||
log_action(&pool, claims.sub, "QUIZ_GENERATED", "Lesson", id, json!({})).await;
|
log_action(
|
||||||
|
&pool,
|
||||||
|
org_ctx.id,
|
||||||
|
claims.sub,
|
||||||
|
"QUIZ_GENERATED",
|
||||||
|
"Lesson",
|
||||||
|
id,
|
||||||
|
json!({}),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
Ok(Json(quiz_blocks))
|
Ok(Json(quiz_blocks))
|
||||||
}
|
}
|
||||||
@@ -985,11 +1002,13 @@ pub async fn create_grading_category(
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub async fn delete_grading_category(
|
pub async fn delete_grading_category(
|
||||||
|
Org(org_ctx): Org,
|
||||||
State(pool): State<PgPool>,
|
State(pool): State<PgPool>,
|
||||||
Path(id): Path<Uuid>,
|
Path(id): Path<Uuid>,
|
||||||
) -> Result<StatusCode, (StatusCode, String)> {
|
) -> Result<StatusCode, (StatusCode, String)> {
|
||||||
sqlx::query("DELETE FROM grading_categories WHERE id = $1")
|
sqlx::query("DELETE FROM grading_categories WHERE id = $1 AND organization_id = $2")
|
||||||
.bind(id)
|
.bind(id)
|
||||||
|
.bind(org_ctx.id)
|
||||||
.execute(&pool)
|
.execute(&pool)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||||
@@ -999,6 +1018,7 @@ pub async fn delete_grading_category(
|
|||||||
|
|
||||||
pub async fn log_action(
|
pub async fn log_action(
|
||||||
pool: &PgPool,
|
pool: &PgPool,
|
||||||
|
organization_id: Uuid,
|
||||||
user_id: Uuid,
|
user_id: Uuid,
|
||||||
action: &str,
|
action: &str,
|
||||||
entity_type: &str,
|
entity_type: &str,
|
||||||
@@ -1006,9 +1026,10 @@ pub async fn log_action(
|
|||||||
changes: serde_json::Value,
|
changes: serde_json::Value,
|
||||||
) {
|
) {
|
||||||
let _ = sqlx::query(
|
let _ = sqlx::query(
|
||||||
"INSERT INTO audit_logs (user_id, action, entity_type, entity_id, changes) VALUES ($1, $2, $3, $4, $5)"
|
"INSERT INTO audit_logs (user_id, organization_id, action, entity_type, entity_id, changes) VALUES ($1, $2, $3, $4, $5, $6)"
|
||||||
)
|
)
|
||||||
.bind(user_id)
|
.bind(user_id)
|
||||||
|
.bind(organization_id)
|
||||||
.bind(action)
|
.bind(action)
|
||||||
.bind(entity_type)
|
.bind(entity_type)
|
||||||
.bind(entity_id)
|
.bind(entity_id)
|
||||||
@@ -1796,6 +1817,7 @@ pub async fn delete_lesson(
|
|||||||
|
|
||||||
// User Management
|
// User Management
|
||||||
pub async fn get_all_users(
|
pub async fn get_all_users(
|
||||||
|
Org(org_ctx): Org,
|
||||||
claims: common::auth::Claims,
|
claims: common::auth::Claims,
|
||||||
State(pool): State<PgPool>,
|
State(pool): State<PgPool>,
|
||||||
) -> Result<Json<Vec<UserResponse>>, StatusCode> {
|
) -> Result<Json<Vec<UserResponse>>, StatusCode> {
|
||||||
@@ -1804,8 +1826,9 @@ pub async fn get_all_users(
|
|||||||
}
|
}
|
||||||
|
|
||||||
let users = sqlx::query_as::<_, UserResponse>(
|
let users = sqlx::query_as::<_, UserResponse>(
|
||||||
"SELECT id, email, full_name, role, organization_id FROM users",
|
"SELECT id, email, full_name, role, organization_id FROM users WHERE organization_id = $1",
|
||||||
)
|
)
|
||||||
|
.bind(org_ctx.id)
|
||||||
.fetch_all(&pool)
|
.fetch_all(&pool)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| {
|
.map_err(|e| {
|
||||||
@@ -1817,13 +1840,14 @@ pub async fn get_all_users(
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub async fn update_user(
|
pub async fn update_user(
|
||||||
|
Org(org_ctx): Org,
|
||||||
claims: common::auth::Claims,
|
claims: common::auth::Claims,
|
||||||
State(pool): State<PgPool>,
|
State(pool): State<PgPool>,
|
||||||
Path(id): Path<Uuid>,
|
Path(id): Path<Uuid>,
|
||||||
Json(payload): Json<serde_json::Value>,
|
Json(payload): Json<serde_json::Value>,
|
||||||
) -> Result<StatusCode, (StatusCode, String)> {
|
) -> Result<StatusCode, (StatusCode, String)> {
|
||||||
if claims.role != "admin" {
|
if claims.role != "admin" && claims.sub != id {
|
||||||
return Err((StatusCode::FORBIDDEN, "Admin access required".into()));
|
return Err((StatusCode::FORBIDDEN, "Not authorized".into()));
|
||||||
}
|
}
|
||||||
|
|
||||||
let role = payload.get("role").and_then(|r| r.as_str());
|
let role = payload.get("role").and_then(|r| r.as_str());
|
||||||
@@ -1833,16 +1857,17 @@ pub async fn update_user(
|
|||||||
.and_then(|o| Uuid::parse_str(o).ok());
|
.and_then(|o| Uuid::parse_str(o).ok());
|
||||||
|
|
||||||
sqlx::query(
|
sqlx::query(
|
||||||
"UPDATE users SET role = COALESCE($1, role), organization_id = COALESCE($2, organization_id) WHERE id = $3"
|
"UPDATE users SET role = COALESCE($1, role), organization_id = COALESCE($2, organization_id) WHERE id = $3 AND organization_id = $4"
|
||||||
)
|
)
|
||||||
.bind(role)
|
.bind(role)
|
||||||
.bind(organization_id)
|
.bind(organization_id)
|
||||||
.bind(id)
|
.bind(id)
|
||||||
|
.bind(org_ctx.id)
|
||||||
.execute(&pool)
|
.execute(&pool)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||||
|
|
||||||
log_action(&pool, claims.sub, "UPDATE_USER", "User", id, payload).await;
|
log_action(&pool, org_ctx.id, claims.sub, "UPDATE_USER", "User", id, payload).await;
|
||||||
|
|
||||||
Ok(StatusCode::OK)
|
Ok(StatusCode::OK)
|
||||||
}
|
}
|
||||||
@@ -1915,7 +1940,7 @@ pub async fn get_webhooks(
|
|||||||
}
|
}
|
||||||
|
|
||||||
let webhooks = sqlx::query_as::<_, common::models::Webhook>(
|
let webhooks = sqlx::query_as::<_, common::models::Webhook>(
|
||||||
"SELECT * FROM webhooks WHERE organization_id = ORDER BY created_at DESC",
|
"SELECT * FROM webhooks WHERE organization_id = $1 ORDER BY created_at DESC",
|
||||||
)
|
)
|
||||||
.bind(org_ctx.id)
|
.bind(org_ctx.id)
|
||||||
.fetch_all(&pool)
|
.fetch_all(&pool)
|
||||||
@@ -1938,7 +1963,7 @@ pub async fn create_webhook(
|
|||||||
let webhook = sqlx::query_as::<_, common::models::Webhook>(
|
let webhook = sqlx::query_as::<_, common::models::Webhook>(
|
||||||
r#"
|
r#"
|
||||||
INSERT INTO webhooks (organization_id, url, events, secret)
|
INSERT INTO webhooks (organization_id, url, events, secret)
|
||||||
VALUES (, , , )
|
VALUES ($1, $2, $3, $4)
|
||||||
RETURNING *
|
RETURNING *
|
||||||
"#,
|
"#,
|
||||||
)
|
)
|
||||||
@@ -1952,6 +1977,7 @@ pub async fn create_webhook(
|
|||||||
|
|
||||||
log_action(
|
log_action(
|
||||||
&pool,
|
&pool,
|
||||||
|
org_ctx.id,
|
||||||
claims.sub,
|
claims.sub,
|
||||||
"CREATE_WEBHOOK",
|
"CREATE_WEBHOOK",
|
||||||
"Webhook",
|
"Webhook",
|
||||||
@@ -1973,7 +1999,7 @@ pub async fn delete_webhook(
|
|||||||
return Err((StatusCode::FORBIDDEN, "Admin access required".into()));
|
return Err((StatusCode::FORBIDDEN, "Admin access required".into()));
|
||||||
}
|
}
|
||||||
|
|
||||||
let result = sqlx::query("DELETE FROM webhooks WHERE id = AND organization_id = ")
|
let result = sqlx::query("DELETE FROM webhooks WHERE id = $1 AND organization_id = $2")
|
||||||
.bind(id)
|
.bind(id)
|
||||||
.bind(org_ctx.id)
|
.bind(org_ctx.id)
|
||||||
.execute(&pool)
|
.execute(&pool)
|
||||||
@@ -1986,6 +2012,7 @@ pub async fn delete_webhook(
|
|||||||
|
|
||||||
log_action(
|
log_action(
|
||||||
&pool,
|
&pool,
|
||||||
|
org_ctx.id,
|
||||||
claims.sub,
|
claims.sub,
|
||||||
"DELETE_WEBHOOK",
|
"DELETE_WEBHOOK",
|
||||||
"Webhook",
|
"Webhook",
|
||||||
|
|||||||
@@ -0,0 +1,59 @@
|
|||||||
|
-- Migration: Update Analytics Functions for Multi-Tenancy
|
||||||
|
-- Scope: fn_get_cohort_analytics, fn_get_retention_data
|
||||||
|
|
||||||
|
-- 1. Update Course Cohort Analytics to include p_organization_id
|
||||||
|
CREATE OR REPLACE FUNCTION fn_get_cohort_analytics(p_course_id UUID, p_organization_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 AND organization_id = p_organization_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 AND organization_id = p_organization_id)
|
||||||
|
AND organization_id = p_organization_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 AND organization_id = p_organization_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;
|
||||||
|
|
||||||
|
-- 2. Update Retention Data Function to include p_organization_id
|
||||||
|
CREATE OR REPLACE FUNCTION fn_get_retention_data(p_course_id UUID, p_organization_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 AND ug.organization_id = p_organization_id
|
||||||
|
WHERE l.module_id IN (SELECT id FROM modules WHERE course_id = p_course_id AND organization_id = p_organization_id)
|
||||||
|
AND l.organization_id = p_organization_id
|
||||||
|
GROUP BY l.id, l.title, l.position
|
||||||
|
ORDER BY l.position;
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql;
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
-- Migration: Add organization_id to remaining content tables (LMS)
|
||||||
|
-- Tables: modules, lessons, grading_categories, user_grades, user_badges, points_log
|
||||||
|
|
||||||
|
-- 1. Add organization_id to modules
|
||||||
|
ALTER TABLE modules ADD COLUMN organization_id UUID NOT NULL DEFAULT '00000000-0000-0000-0000-000000000001';
|
||||||
|
ALTER TABLE modules ADD CONSTRAINT fk_module_organization FOREIGN KEY (organization_id) REFERENCES organizations(id) ON DELETE CASCADE;
|
||||||
|
ALTER TABLE modules ALTER COLUMN organization_id DROP DEFAULT;
|
||||||
|
|
||||||
|
-- 2. Add organization_id to lessons
|
||||||
|
ALTER TABLE lessons ADD COLUMN organization_id UUID NOT NULL DEFAULT '00000000-0000-0000-0000-000000000001';
|
||||||
|
ALTER TABLE lessons ADD CONSTRAINT fk_lesson_organization FOREIGN KEY (organization_id) REFERENCES organizations(id) ON DELETE CASCADE;
|
||||||
|
ALTER TABLE lessons ALTER COLUMN organization_id DROP DEFAULT;
|
||||||
|
|
||||||
|
-- 3. Add organization_id to grading_categories
|
||||||
|
ALTER TABLE grading_categories ADD COLUMN organization_id UUID NOT NULL DEFAULT '00000000-0000-0000-0000-000000000001';
|
||||||
|
ALTER TABLE grading_categories ADD CONSTRAINT fk_grading_category_organization FOREIGN KEY (organization_id) REFERENCES organizations(id) ON DELETE CASCADE;
|
||||||
|
ALTER TABLE grading_categories ALTER COLUMN organization_id DROP DEFAULT;
|
||||||
|
|
||||||
|
-- 4. Add organization_id to user_grades
|
||||||
|
ALTER TABLE user_grades ADD COLUMN organization_id UUID NOT NULL DEFAULT '00000000-0000-0000-0000-000000000001';
|
||||||
|
ALTER TABLE user_grades ADD CONSTRAINT fk_user_grade_organization FOREIGN KEY (organization_id) REFERENCES organizations(id) ON DELETE CASCADE;
|
||||||
|
ALTER TABLE user_grades ALTER COLUMN organization_id DROP DEFAULT;
|
||||||
|
|
||||||
|
-- 5. Add organization_id to user_badges
|
||||||
|
ALTER TABLE user_badges ADD COLUMN organization_id UUID NOT NULL DEFAULT '00000000-0000-0000-0000-000000000001';
|
||||||
|
ALTER TABLE user_badges ADD CONSTRAINT fk_user_badge_organization FOREIGN KEY (organization_id) REFERENCES organizations(id) ON DELETE CASCADE;
|
||||||
|
ALTER TABLE user_badges ALTER COLUMN organization_id DROP DEFAULT;
|
||||||
|
|
||||||
|
-- 6. Add organization_id to points_log
|
||||||
|
ALTER TABLE points_log ADD COLUMN organization_id UUID NOT NULL DEFAULT '00000000-0000-0000-0000-000000000001';
|
||||||
|
ALTER TABLE points_log ADD CONSTRAINT fk_points_log_organization FOREIGN KEY (organization_id) REFERENCES organizations(id) ON DELETE CASCADE;
|
||||||
|
ALTER TABLE points_log ALTER COLUMN organization_id DROP DEFAULT;
|
||||||
@@ -215,12 +215,28 @@ pub async fn login(
|
|||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct CatalogQuery {
|
||||||
|
pub organization_id: Option<Uuid>,
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn get_course_catalog(
|
pub async fn get_course_catalog(
|
||||||
State(pool): State<PgPool>,
|
State(pool): State<PgPool>,
|
||||||
|
Query(query): Query<CatalogQuery>,
|
||||||
) -> Result<Json<Vec<Course>>, StatusCode> {
|
) -> Result<Json<Vec<Course>>, StatusCode> {
|
||||||
let courses = sqlx::query_as::<_, Course>("SELECT * FROM courses")
|
let courses = match query.organization_id {
|
||||||
|
Some(org_id) => {
|
||||||
|
sqlx::query_as::<_, Course>("SELECT * FROM courses WHERE organization_id = $1")
|
||||||
|
.bind(org_id)
|
||||||
.fetch_all(&pool)
|
.fetch_all(&pool)
|
||||||
.await
|
.await
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
sqlx::query_as::<_, Course>("SELECT * FROM courses")
|
||||||
|
.fetch_all(&pool)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
}
|
||||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||||
|
|
||||||
Ok(Json(courses))
|
Ok(Json(courses))
|
||||||
@@ -543,14 +559,16 @@ pub async fn submit_lesson_score(
|
|||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
// TODO: Detect course completion logic
|
// 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)")
|
let total_lessons: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM lessons WHERE organization_id = $1 AND module_id IN (SELECT id FROM modules WHERE course_id = $2)")
|
||||||
|
.bind(org_ctx.id)
|
||||||
.bind(payload.course_id)
|
.bind(payload.course_id)
|
||||||
.fetch_one(&pool).await.unwrap_or(0);
|
.fetch_one(&pool).await.unwrap_or(0);
|
||||||
|
|
||||||
let completed_lessons: i64 = sqlx::query_scalar(
|
let completed_lessons: i64 = sqlx::query_scalar(
|
||||||
"SELECT COUNT(*) FROM user_grades WHERE user_id = $1 AND course_id = $2",
|
"SELECT COUNT(*) FROM user_grades WHERE organization_id = $1 AND user_id = $2 AND course_id = $3",
|
||||||
)
|
)
|
||||||
|
.bind(org_ctx.id)
|
||||||
.bind(payload.user_id)
|
.bind(payload.user_id)
|
||||||
.bind(payload.course_id)
|
.bind(payload.course_id)
|
||||||
.fetch_one(&pool)
|
.fetch_one(&pool)
|
||||||
@@ -590,12 +608,13 @@ pub struct BadgeResponse {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub async fn get_user_gamification(
|
pub async fn get_user_gamification(
|
||||||
Org(_org_ctx): Org,
|
Org(org_ctx): Org,
|
||||||
State(pool): State<PgPool>,
|
State(pool): State<PgPool>,
|
||||||
Path(user_id): Path<Uuid>,
|
Path(user_id): Path<Uuid>,
|
||||||
) -> Result<Json<GamificationStatus>, StatusCode> {
|
) -> Result<Json<GamificationStatus>, StatusCode> {
|
||||||
let user_stats: (i32, i32) = sqlx::query_as("SELECT xp, level FROM users WHERE id = $1")
|
let user_stats: (i32, i32) = sqlx::query_as("SELECT xp, level FROM users WHERE id = $1 AND organization_id = $2")
|
||||||
.bind(user_id)
|
.bind(user_id)
|
||||||
|
.bind(org_ctx.id)
|
||||||
.fetch_one(&pool)
|
.fetch_one(&pool)
|
||||||
.await
|
.await
|
||||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||||
@@ -604,9 +623,10 @@ pub async fn get_user_gamification(
|
|||||||
"SELECT b.id, b.name, b.description, b.icon_url, ub.earned_at
|
"SELECT b.id, b.name, b.description, b.icon_url, ub.earned_at
|
||||||
FROM user_badges ub
|
FROM user_badges ub
|
||||||
JOIN badges b ON ub.badge_id = b.id
|
JOIN badges b ON ub.badge_id = b.id
|
||||||
WHERE ub.user_id = $1",
|
WHERE ub.user_id = $1 AND ub.organization_id = $2",
|
||||||
)
|
)
|
||||||
.bind(user_id)
|
.bind(user_id)
|
||||||
|
.bind(org_ctx.id)
|
||||||
.fetch_all(&pool)
|
.fetch_all(&pool)
|
||||||
.await
|
.await
|
||||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||||
@@ -732,15 +752,16 @@ pub async fn get_course_analytics(
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub async fn get_advanced_analytics(
|
pub async fn get_advanced_analytics(
|
||||||
Org(_org_ctx): Org,
|
Org(org_ctx): Org,
|
||||||
State(pool): State<PgPool>,
|
State(pool): State<PgPool>,
|
||||||
Path(course_id): Path<Uuid>,
|
Path(course_id): Path<Uuid>,
|
||||||
) -> Result<Json<common::models::AdvancedAnalytics>, StatusCode> {
|
) -> Result<Json<common::models::AdvancedAnalytics>, StatusCode> {
|
||||||
// 1. Cohort Analysis using DB function
|
// 1. Cohort Analysis using DB function
|
||||||
let cohort_data = sqlx::query_as::<_, common::models::CohortData>(
|
let cohort_data = sqlx::query_as::<_, common::models::CohortData>(
|
||||||
"SELECT period, student_count as count, completion_rate FROM fn_get_cohort_analytics($1)",
|
"SELECT period, student_count as count, completion_rate FROM fn_get_cohort_analytics($1, $2)",
|
||||||
)
|
)
|
||||||
.bind(course_id)
|
.bind(course_id)
|
||||||
|
.bind(org_ctx.id)
|
||||||
.fetch_all(&pool)
|
.fetch_all(&pool)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| {
|
.map_err(|e| {
|
||||||
@@ -750,9 +771,10 @@ pub async fn get_advanced_analytics(
|
|||||||
|
|
||||||
// 2. Retention Analysis using DB function
|
// 2. Retention Analysis using DB function
|
||||||
let retention_data = sqlx::query_as::<_, common::models::RetentionData>(
|
let retention_data = sqlx::query_as::<_, common::models::RetentionData>(
|
||||||
"SELECT lesson_id, lesson_title, student_count FROM fn_get_retention_data($1)",
|
"SELECT lesson_id, lesson_title, student_count FROM fn_get_retention_data($1, $2)",
|
||||||
)
|
)
|
||||||
.bind(course_id)
|
.bind(course_id)
|
||||||
|
.bind(org_ctx.id)
|
||||||
.fetch_all(&pool)
|
.fetch_all(&pool)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| {
|
.map_err(|e| {
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ pub async fn org_extractor_middleware(
|
|||||||
// NOTA: El secreto debe venir de una variable de entorno en producción.
|
// NOTA: El secreto debe venir de una variable de entorno en producción.
|
||||||
let secret = std::env::var("JWT_SECRET").unwrap_or_else(|_| "secret".to_string());
|
let secret = std::env::var("JWT_SECRET").unwrap_or_else(|_| "secret".to_string());
|
||||||
|
|
||||||
let claims = decode::<Claims>(
|
let mut claims = decode::<Claims>(
|
||||||
token,
|
token,
|
||||||
&DecodingKey::from_secret(secret.as_ref()),
|
&DecodingKey::from_secret(secret.as_ref()),
|
||||||
&Validation::default(),
|
&Validation::default(),
|
||||||
@@ -42,8 +42,24 @@ pub async fn org_extractor_middleware(
|
|||||||
.map_err(|_| StatusCode::UNAUTHORIZED)?
|
.map_err(|_| StatusCode::UNAUTHORIZED)?
|
||||||
.claims;
|
.claims;
|
||||||
|
|
||||||
|
// Check for organization override header (only for admins)
|
||||||
|
let org_id = if claims.role == "admin" {
|
||||||
|
req.headers()
|
||||||
|
.get("x-organization-id")
|
||||||
|
.and_then(|h| h.to_str().ok())
|
||||||
|
.and_then(|s| Uuid::parse_str(s).ok())
|
||||||
|
.unwrap_or(claims.org)
|
||||||
|
} else {
|
||||||
|
claims.org
|
||||||
|
};
|
||||||
|
|
||||||
|
// Update claims.org if overridden so downstream logic sees the new org
|
||||||
|
if org_id != claims.org {
|
||||||
|
claims.org = org_id;
|
||||||
|
}
|
||||||
|
|
||||||
// Insertamos el contexto y las claims en las extensiones de la petición.
|
// Insertamos el contexto y las claims en las extensiones de la petición.
|
||||||
req.extensions_mut().insert(OrgContext { id: claims.org });
|
req.extensions_mut().insert(OrgContext { id: org_id });
|
||||||
req.extensions_mut().insert(claims);
|
req.extensions_mut().insert(claims);
|
||||||
|
|
||||||
Ok(next.run(req).await)
|
Ok(next.run(req).await)
|
||||||
|
|||||||
+43
-16
@@ -16,23 +16,50 @@ else
|
|||||||
echo ""
|
echo ""
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# 2. Verify New Registration
|
# 3. Verify Organization Context (Course Scoping)
|
||||||
echo "Testing Registration for newuser@test.com..."
|
echo "Testing Course Scoping by Organization..."
|
||||||
# Clear if exists
|
# Login to get token
|
||||||
docker exec openccb-db-1 psql -U user -d openccb_cms -c "DELETE FROM users WHERE email='newuser@test.com';" > /dev/null 2>&1
|
USER_DATA=$(curl -s -X POST http://localhost:3001/auth/login \
|
||||||
|
|
||||||
HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" -X POST http://localhost:3001/auth/register \
|
|
||||||
-H "Content-Type: application/json" \
|
-H "Content-Type: application/json" \
|
||||||
-d '{"email":"newuser@test.com","password":"password123","full_name":"New User","role":"instructor"}')
|
-d '{"email":"juan.allende@gmail.com","password":"password123"}')
|
||||||
|
TOKEN=$(echo "$USER_DATA" | jq -r '.token')
|
||||||
|
ORG_ID=$(echo "$USER_DATA" | jq -r '.user.organization_id')
|
||||||
|
|
||||||
if [ "$HTTP_CODE" -eq 200 ]; then
|
if [ "$TOKEN" != "null" ]; then
|
||||||
echo "SUCCESS: Registration worked for newuser@test.com"
|
echo "SUCCESS: Got token for juan.allende@gmail.com"
|
||||||
# Cleanup
|
# Try to list courses
|
||||||
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 GET http://localhost:3001/courses \
|
||||||
|
-H "Authorization: Bearer $TOKEN")
|
||||||
|
|
||||||
|
if [ "$HTTP_CODE" -eq 200 ]; then
|
||||||
|
echo "SUCCESS: Courses retrieved successfully with organization scope"
|
||||||
|
else
|
||||||
|
echo "FAIL: Failed to retrieve courses (Status: $HTTP_CODE)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 4. Verify Admin Context Switching (X-Organization-Id)
|
||||||
|
# Create a dummy organization to test switching
|
||||||
|
echo "Testing Admin Context Switching (X-Organization-Id)..."
|
||||||
|
NEW_ORG_ID=$(curl -s -X POST http://localhost:3001/organizations \
|
||||||
|
-H "Authorization: Bearer $TOKEN" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"name": "Context Switching Test"}' | jq -r '.id')
|
||||||
|
|
||||||
|
if [ "$NEW_ORG_ID" != "null" ]; then
|
||||||
|
echo "SUCCESS: New organization created ($NEW_ORG_ID)"
|
||||||
|
# Try to list courses using the new org context
|
||||||
|
HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" -X GET http://localhost:3001/courses \
|
||||||
|
-H "Authorization: Bearer $TOKEN" \
|
||||||
|
-H "X-Organization-Id: $NEW_ORG_ID")
|
||||||
|
|
||||||
|
if [ "$HTTP_CODE" -eq 200 ]; then
|
||||||
|
echo "SUCCESS: Context switching worked via X-Organization-Id"
|
||||||
|
else
|
||||||
|
echo "FAIL: Context switching failed (Status: $HTTP_CODE)"
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
echo "FAIL: Could not create test organization"
|
||||||
|
fi
|
||||||
else
|
else
|
||||||
echo "FAIL: Registration failed with status $HTTP_CODE"
|
echo "FAIL: Could not get token for testing organization context"
|
||||||
curl -s -X POST http://localhost:3001/auth/register \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
-d '{"email":"newuser@test.com","password":"password123","full_name":"New User","role":"instructor"}'
|
|
||||||
echo ""
|
|
||||||
fi
|
fi
|
||||||
|
|||||||
@@ -127,86 +127,85 @@ export interface Module {
|
|||||||
lessons: Lesson[];
|
lessons: Lesson[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export const lmsApi = {
|
const getToken = () => typeof window !== 'undefined' ? localStorage.getItem('token') : null;
|
||||||
async getCatalog(): Promise<Course[]> {
|
|
||||||
// LMS service uses /catalog for the published courses list
|
const apiFetch = async (url: string, options: RequestInit = {}, isCMS: boolean = false) => {
|
||||||
const response = await fetch(`${API_BASE_URL}/catalog`);
|
const token = getToken();
|
||||||
if (!response.ok) throw new Error('Failed to fetch catalog');
|
const baseUrl = isCMS ? CMS_API_URL : API_BASE_URL;
|
||||||
|
const headers = {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
...options.headers,
|
||||||
|
...(token ? { 'Authorization': `Bearer ${token}` } : {})
|
||||||
|
};
|
||||||
|
|
||||||
|
const response = await fetch(`${baseUrl}${url}`, { ...options, headers });
|
||||||
|
if (!response.ok) {
|
||||||
|
const error = await response.json().catch(() => ({ message: response.statusText }));
|
||||||
|
throw new Error(error.message || 'An error occurred');
|
||||||
|
}
|
||||||
|
if (response.status === 204) return;
|
||||||
return response.json();
|
return response.json();
|
||||||
|
};
|
||||||
|
|
||||||
|
export const lmsApi = {
|
||||||
|
async getCatalog(orgId?: string): Promise<Course[]> {
|
||||||
|
const query = orgId ? `?organization_id=${orgId}` : '';
|
||||||
|
return apiFetch(`/catalog${query}`);
|
||||||
},
|
},
|
||||||
|
|
||||||
async getCourseOutline(courseId: string): Promise<Course & { modules: Module[], grading_categories: GradingCategory[] }> {
|
async getCourseOutline(courseId: string): Promise<Course & { modules: Module[], grading_categories: GradingCategory[] }> {
|
||||||
const response = await fetch(`${API_BASE_URL}/courses/${courseId}/outline`);
|
return apiFetch(`/courses/${courseId}/outline`);
|
||||||
if (!response.ok) throw new Error('Failed to fetch course outline');
|
|
||||||
return response.json();
|
|
||||||
},
|
},
|
||||||
|
|
||||||
async getLesson(id: string): Promise<Lesson> {
|
async getLesson(id: string): Promise<Lesson> {
|
||||||
return fetch(`${API_BASE_URL}/lessons/${id}`).then(res => res.json());
|
return apiFetch(`/lessons/${id}`);
|
||||||
},
|
},
|
||||||
|
|
||||||
async register(payload: AuthPayload): Promise<AuthResponse> {
|
async register(payload: AuthPayload): Promise<AuthResponse> {
|
||||||
return fetch(`${API_BASE_URL}/auth/register`, {
|
return apiFetch('/auth/register', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify(payload)
|
body: JSON.stringify(payload)
|
||||||
}).then(res => res.ok ? res.json() : res.json().then(e => Promise.reject(e)));
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
async login(payload: AuthPayload): Promise<AuthResponse> {
|
async login(payload: AuthPayload): Promise<AuthResponse> {
|
||||||
return fetch(`${API_BASE_URL}/auth/login`, {
|
return apiFetch('/auth/login', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify(payload)
|
body: JSON.stringify(payload)
|
||||||
}).then(res => res.ok ? res.json() : res.json().then(e => Promise.reject(e)));
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
async enroll(courseId: string, userId: string): Promise<void> {
|
async enroll(courseId: string, userId: string): Promise<void> {
|
||||||
return fetch(`${API_BASE_URL}/enroll`, {
|
return apiFetch('/enroll', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ course_id: courseId, user_id: userId })
|
body: JSON.stringify({ course_id: courseId, user_id: userId })
|
||||||
}).then(res => res.ok ? res.json() : res.json().then(e => Promise.reject(e)));
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
async getEnrollments(userId: string): Promise<Enrollment[]> {
|
async getEnrollments(userId: string): Promise<Enrollment[]> {
|
||||||
return fetch(`${API_BASE_URL}/enrollments/${userId}`).then(res => res.json());
|
return apiFetch(`/enrollments/${userId}`);
|
||||||
},
|
},
|
||||||
|
|
||||||
async submitScore(userId: string, courseId: string, lessonId: string, score: number): Promise<UserGrade> {
|
async submitScore(userId: string, courseId: string, lessonId: string, score: number): Promise<UserGrade> {
|
||||||
const response = await fetch(`${API_BASE_URL}/grades`, {
|
return apiFetch('/grades', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ user_id: userId, course_id: courseId, lesson_id: lessonId, score })
|
body: JSON.stringify({ user_id: userId, course_id: courseId, lesson_id: lessonId, score })
|
||||||
});
|
});
|
||||||
if (!response.ok) throw new Error('Failed to submit score');
|
|
||||||
return response.json();
|
|
||||||
},
|
},
|
||||||
|
|
||||||
async getUserGrades(userId: string, courseId: string): Promise<UserGrade[]> {
|
async getUserGrades(userId: string, courseId: string): Promise<UserGrade[]> {
|
||||||
const response = await fetch(`${API_BASE_URL}/users/${userId}/courses/${courseId}/grades`);
|
return apiFetch(`/users/${userId}/courses/${courseId}/grades`);
|
||||||
if (!response.ok) throw new Error('Failed to fetch user grades');
|
|
||||||
return response.json();
|
|
||||||
},
|
},
|
||||||
|
|
||||||
async getGamification(userId: string): Promise<{ points: number, level: number, badges: { id: string, name: string, description: string, earned_at: 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`);
|
return apiFetch(`/users/${userId}/gamification`);
|
||||||
if (!response.ok) throw new Error('Failed to fetch gamification data');
|
|
||||||
return response.json();
|
|
||||||
},
|
},
|
||||||
|
|
||||||
async getLeaderboard(): Promise<User[]> {
|
async getLeaderboard(): Promise<User[]> {
|
||||||
const token = localStorage.getItem('token');
|
return apiFetch('/analytics/leaderboard');
|
||||||
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> {
|
async getBranding(orgId: string): Promise<Organization> {
|
||||||
const response = await fetch(`${CMS_API_URL}/organizations/${orgId}/branding`);
|
return apiFetch(`/organizations/${orgId}/branding`, {}, true);
|
||||||
if (!response.ok) throw new Error('Failed to fetch branding');
|
|
||||||
return response.json();
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -6,8 +6,10 @@ import { User } from '@/lib/api';
|
|||||||
interface AuthContextType {
|
interface AuthContextType {
|
||||||
user: User | null;
|
user: User | null;
|
||||||
token: string | null;
|
token: string | null;
|
||||||
|
selectedOrgId: string | null;
|
||||||
login: (user: User, token: string) => void;
|
login: (user: User, token: string) => void;
|
||||||
logout: () => void;
|
logout: () => void;
|
||||||
|
setOrganizationId: (id: string | null) => void;
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -16,14 +18,19 @@ const AuthContext = createContext<AuthContextType | undefined>(undefined);
|
|||||||
export function AuthProvider({ children }: { children: React.ReactNode }) {
|
export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||||
const [user, setUser] = useState<User | null>(null);
|
const [user, setUser] = useState<User | null>(null);
|
||||||
const [token, setToken] = useState<string | null>(null);
|
const [token, setToken] = useState<string | null>(null);
|
||||||
|
const [selectedOrgId, setSelectedOrgId] = useState<string | null>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const savedUser = localStorage.getItem('studio_user');
|
const savedUser = localStorage.getItem('studio_user');
|
||||||
const savedToken = localStorage.getItem('studio_token');
|
const savedToken = localStorage.getItem('studio_token');
|
||||||
|
const savedOrgId = localStorage.getItem('studio_selected_org_id');
|
||||||
|
|
||||||
if (savedUser && savedToken) {
|
if (savedUser && savedToken) {
|
||||||
setUser(JSON.parse(savedUser));
|
const u = JSON.parse(savedUser);
|
||||||
|
setUser(u);
|
||||||
setToken(savedToken);
|
setToken(savedToken);
|
||||||
|
setSelectedOrgId(savedOrgId || u.organization_id || null);
|
||||||
}
|
}
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}, []);
|
}, []);
|
||||||
@@ -31,19 +38,34 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
|||||||
const login = (newUser: User, newToken: string) => {
|
const login = (newUser: User, newToken: string) => {
|
||||||
setUser(newUser);
|
setUser(newUser);
|
||||||
setToken(newToken);
|
setToken(newToken);
|
||||||
|
setSelectedOrgId(newUser.organization_id || null);
|
||||||
localStorage.setItem('studio_user', JSON.stringify(newUser));
|
localStorage.setItem('studio_user', JSON.stringify(newUser));
|
||||||
localStorage.setItem('studio_token', newToken);
|
localStorage.setItem('studio_token', newToken);
|
||||||
|
if (newUser.organization_id) {
|
||||||
|
localStorage.setItem('studio_selected_org_id', newUser.organization_id);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const logout = () => {
|
const logout = () => {
|
||||||
setUser(null);
|
setUser(null);
|
||||||
setToken(null);
|
setToken(null);
|
||||||
|
setSelectedOrgId(null);
|
||||||
localStorage.removeItem('studio_user');
|
localStorage.removeItem('studio_user');
|
||||||
localStorage.removeItem('studio_token');
|
localStorage.removeItem('studio_token');
|
||||||
|
localStorage.removeItem('studio_selected_org_id');
|
||||||
|
};
|
||||||
|
|
||||||
|
const setOrganizationId = (id: string | null) => {
|
||||||
|
setSelectedOrgId(id);
|
||||||
|
if (id) {
|
||||||
|
localStorage.setItem('studio_selected_org_id', id);
|
||||||
|
} else {
|
||||||
|
localStorage.removeItem('studio_selected_org_id');
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AuthContext.Provider value={{ user, token, login, logout, loading }}>
|
<AuthContext.Provider value={{ user, token, selectedOrgId, login, logout, setOrganizationId, loading }}>
|
||||||
{children}
|
{children}
|
||||||
</AuthContext.Provider>
|
</AuthContext.Provider>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -191,13 +191,16 @@ export interface CreateWebhookPayload {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const getToken = () => typeof window !== 'undefined' ? localStorage.getItem('studio_token') : null;
|
const getToken = () => typeof window !== 'undefined' ? localStorage.getItem('studio_token') : null;
|
||||||
|
const getSelectedOrgId = () => typeof window !== 'undefined' ? localStorage.getItem('studio_selected_org_id') : null;
|
||||||
|
|
||||||
const apiFetch = (url: string, options: RequestInit = {}) => {
|
const apiFetch = (url: string, options: RequestInit = {}) => {
|
||||||
const token = getToken();
|
const token = getToken();
|
||||||
|
const selectedOrgId = getSelectedOrgId();
|
||||||
const headers = {
|
const headers = {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
...options.headers,
|
...options.headers,
|
||||||
...(token ? { 'Authorization': `Bearer ${token}` } : {})
|
...(token ? { 'Authorization': `Bearer ${token}` } : {}),
|
||||||
|
...(selectedOrgId ? { 'X-Organization-Id': selectedOrgId } : {})
|
||||||
};
|
};
|
||||||
|
|
||||||
return fetch(`${API_BASE_URL}${url}`, { ...options, headers }).then(async res => {
|
return fetch(`${API_BASE_URL}${url}`, { ...options, headers }).then(async res => {
|
||||||
@@ -273,8 +276,10 @@ export const cmsApi = {
|
|||||||
formData.append('file', file);
|
formData.append('file', file);
|
||||||
|
|
||||||
const token = getToken();
|
const token = getToken();
|
||||||
|
const selectedOrgId = getSelectedOrgId();
|
||||||
const headers: Record<string, string> = {
|
const headers: Record<string, string> = {
|
||||||
...(token ? { 'Authorization': `Bearer ${token}` } : {})
|
...(token ? { 'Authorization': `Bearer ${token}` } : {}),
|
||||||
|
...(selectedOrgId ? { 'X-Organization-Id': selectedOrgId } : {})
|
||||||
};
|
};
|
||||||
|
|
||||||
// Note: We don't set 'Content-Type' for multipart/form-data.
|
// Note: We don't set 'Content-Type' for multipart/form-data.
|
||||||
@@ -295,8 +300,10 @@ export const cmsApi = {
|
|||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
formData.append('file', file);
|
formData.append('file', file);
|
||||||
const token = getToken();
|
const token = getToken();
|
||||||
|
const selectedOrgId = getSelectedOrgId();
|
||||||
const headers: Record<string, string> = {
|
const headers: Record<string, string> = {
|
||||||
...(token ? { 'Authorization': `Bearer ${token}` } : {})
|
...(token ? { 'Authorization': `Bearer ${token}` } : {}),
|
||||||
|
...(selectedOrgId ? { 'X-Organization-Id': selectedOrgId } : {})
|
||||||
};
|
};
|
||||||
return fetch(`${API_BASE_URL}/organizations/${id}/logo`, {
|
return fetch(`${API_BASE_URL}/organizations/${id}/logo`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
|
|||||||
Reference in New Issue
Block a user