From daeda7e905d36b20a48cbd09d943ed6f187cb93d Mon Sep 17 00:00:00 2001 From: Nurfog Date: Thu, 15 Jan 2026 11:40:38 -0300 Subject: [PATCH] feat: Implement multi-tenancy with organization ID in LMS tables and middleware, refactor web API calls, and update analytics and gamification features." --- README.md | 27 ++++--- install.sh | 3 + roadmap.md | 12 +-- services/cms-service/src/handlers.rs | 57 ++++++++++---- ...60115000000_update_analytics_functions.sql | 59 +++++++++++++++ .../20260115000001_add_org_to_all_tables.sql | 32 ++++++++ services/lms-service/src/handlers.rs | 48 ++++++++---- shared/common/src/middleware.rs | 20 ++++- validate_auth.sh | 59 +++++++++++---- web/experience/src/lib/api.ts | 75 +++++++++---------- web/studio/src/context/AuthContext.tsx | 26 ++++++- web/studio/src/lib/api.ts | 13 +++- 12 files changed, 325 insertions(+), 106 deletions(-) create mode 100644 services/lms-service/migrations/20260115000000_update_analytics_functions.sql create mode 100644 services/lms-service/migrations/20260115000001_add_org_to_all_tables.sql diff --git a/README.md b/README.md index 6b231be..3103884 100644 --- a/README.md +++ b/README.md @@ -246,17 +246,21 @@ curl -X POST "http://localhost:8000/chat" \ #### GET /courses/{id}/analytics/advanced Métricas de retención y análisis de cohortes. -- **Respuesta ( AdvancedAnalyticsResponse ):** - ```json - { - "cohorts": [ - { "period": "string", "count": "int", "completion_rate": "float" } - ], - "retention": [ - { "lesson_id": "uuid", "lesson_title": "string", "student_count": "int" } - ] - } - ``` +--- + +### 5. Multi-tenencia y Gestión (Solo Admin) +OpenCCB permite gestionar múltiples organizaciones desde un único punto de acceso. + +#### X-Organization-Id Header +Los administradores pueden simular el contexto de cualquier organización enviando este encabezado: +```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. - **Leaderboards**: Rankings dentro de la organización. - **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 Este proyecto es código abierto y está disponible bajo los términos de la licencia especificada en el repositorio. \ No newline at end of file diff --git a/install.sh b/install.sh index ae702ee..62a49e0 100755 --- a/install.sh +++ b/install.sh @@ -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 "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 "JWT_SECRET" "supersecretsecret" + update_env "NEXT_PUBLIC_CMS_API_URL" "http://localhost:3001" + update_env "NEXT_PUBLIC_LMS_API_URL" "http://localhost:3002" fi # 5. AI Stack Setup diff --git a/roadmap.md b/roadmap.md index 9dfbd59..76c646b 100644 --- a/roadmap.md +++ b/roadmap.md @@ -81,19 +81,19 @@ - [x] Custom color schemes (Primary/Secondary) - [x] Dynamic Experience Portal adaptation - [x] Live Branding Preview in Studio -- [ ] **Advanced Analytics**: - - [ ] Cohort analysis - - [ ] Retention metrics +- [x] **Advanced Analytics**: + - [x] Cohort analysis (Implemented) + - [x] Retention metrics (Implemented) - [ ] Engagement heatmaps - [ ] **AI Integration** (Next Up): - [x] AI-driven lesson summaries (Implemented) - [ ] Implement real-time video transcription via external API - [x] Automated quiz generation (Implemented) - [ ] Personalized learning paths -- [x] **Gamification**: (Base system implemented) +- [x] **Gamification**: (Broadly implemented) - [x] Badges and achievements (Implemented base system) - - [ ] Leaderboards - - [x] XP and leveling system + - [x] Leaderboards (Implemented) + - [x] XP and leveling system (Implemented) - [x] **Course Management Enhancements**: - [x] Manual naming for modules, lessons, and activities during creation. - [x] Reordering for modules, lessons, and activities (Level up/down). diff --git a/services/cms-service/src/handlers.rs b/services/cms-service/src/handlers.rs index ea0ff2f..0ada178 100644 --- a/services/cms-service/src/handlers.rs +++ b/services/cms-service/src/handlers.rs @@ -88,7 +88,7 @@ pub async fn publish_course( 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 let webhook_service = WebhookService::new(pool.clone()); @@ -484,13 +484,15 @@ pub async fn create_lesson( } pub async fn process_transcription( + Org(org_ctx): Org, claims: common::auth::Claims, State(pool): State, Path(id): Path, ) -> Result, StatusCode> { // 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(org_ctx.id) .fetch_one(&pool) .await .map_err(|e| { @@ -600,6 +602,7 @@ pub async fn process_transcription( log_action( &pool, + org_ctx.id, claims.sub, "TRANSCRIPTION_PROCESSED", "Lesson", @@ -612,13 +615,15 @@ pub async fn process_transcription( } pub async fn summarize_lesson( + Org(org_ctx): Org, claims: common::auth::Claims, State(pool): State, Path(id): Path, ) -> Result, StatusCode> { // 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(org_ctx.id) .fetch_one(&pool) .await .map_err(|_| StatusCode::NOT_FOUND)?; @@ -708,6 +713,7 @@ pub async fn summarize_lesson( log_action( &pool, + org_ctx.id, claims.sub, "SUMMARY_GENERATED", "Lesson", @@ -720,13 +726,15 @@ pub async fn summarize_lesson( } pub async fn generate_quiz( + Org(org_ctx): Org, claims: common::auth::Claims, State(pool): State, Path(id): Path, ) -> Result, StatusCode> { // 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(org_ctx.id) .fetch_one(&pool) .await .map_err(|_| StatusCode::NOT_FOUND)?; @@ -816,7 +824,16 @@ pub async fn generate_quiz( .cloned() .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)) } @@ -985,11 +1002,13 @@ pub async fn create_grading_category( } pub async fn delete_grading_category( + Org(org_ctx): Org, State(pool): State, Path(id): Path, ) -> Result { - 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(org_ctx.id) .execute(&pool) .await .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( pool: &PgPool, + organization_id: Uuid, user_id: Uuid, action: &str, entity_type: &str, @@ -1006,9 +1026,10 @@ pub async fn log_action( changes: serde_json::Value, ) { 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(organization_id) .bind(action) .bind(entity_type) .bind(entity_id) @@ -1796,6 +1817,7 @@ pub async fn delete_lesson( // User Management pub async fn get_all_users( + Org(org_ctx): Org, claims: common::auth::Claims, State(pool): State, ) -> Result>, StatusCode> { @@ -1804,8 +1826,9 @@ pub async fn get_all_users( } 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) .await .map_err(|e| { @@ -1817,13 +1840,14 @@ pub async fn get_all_users( } pub async fn update_user( + Org(org_ctx): Org, claims: common::auth::Claims, State(pool): State, Path(id): Path, Json(payload): Json, ) -> Result { - if claims.role != "admin" { - return Err((StatusCode::FORBIDDEN, "Admin access required".into())); + if claims.role != "admin" && claims.sub != id { + return Err((StatusCode::FORBIDDEN, "Not authorized".into())); } 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()); 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(organization_id) .bind(id) + .bind(org_ctx.id) .execute(&pool) .await .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) } @@ -1915,7 +1940,7 @@ pub async fn get_webhooks( } 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) .fetch_all(&pool) @@ -1938,7 +1963,7 @@ pub async fn create_webhook( let webhook = sqlx::query_as::<_, common::models::Webhook>( r#" INSERT INTO webhooks (organization_id, url, events, secret) - VALUES (, , , ) + VALUES ($1, $2, $3, $4) RETURNING * "#, ) @@ -1952,6 +1977,7 @@ pub async fn create_webhook( log_action( &pool, + org_ctx.id, claims.sub, "CREATE_WEBHOOK", "Webhook", @@ -1973,7 +1999,7 @@ pub async fn delete_webhook( 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(org_ctx.id) .execute(&pool) @@ -1986,6 +2012,7 @@ pub async fn delete_webhook( log_action( &pool, + org_ctx.id, claims.sub, "DELETE_WEBHOOK", "Webhook", diff --git a/services/lms-service/migrations/20260115000000_update_analytics_functions.sql b/services/lms-service/migrations/20260115000000_update_analytics_functions.sql new file mode 100644 index 0000000..5ebbfbc --- /dev/null +++ b/services/lms-service/migrations/20260115000000_update_analytics_functions.sql @@ -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; diff --git a/services/lms-service/migrations/20260115000001_add_org_to_all_tables.sql b/services/lms-service/migrations/20260115000001_add_org_to_all_tables.sql new file mode 100644 index 0000000..534c9f3 --- /dev/null +++ b/services/lms-service/migrations/20260115000001_add_org_to_all_tables.sql @@ -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; diff --git a/services/lms-service/src/handlers.rs b/services/lms-service/src/handlers.rs index 9196e14..4dd5321 100644 --- a/services/lms-service/src/handlers.rs +++ b/services/lms-service/src/handlers.rs @@ -215,13 +215,29 @@ pub async fn login( })) } +#[derive(Deserialize)] +pub struct CatalogQuery { + pub organization_id: Option, +} + pub async fn get_course_catalog( State(pool): State, + Query(query): Query, ) -> Result>, StatusCode> { - let courses = sqlx::query_as::<_, Course>("SELECT * FROM courses") - .fetch_all(&pool) - .await - .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + 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) + .await + } + None => { + sqlx::query_as::<_, Course>("SELECT * FROM courses") + .fetch_all(&pool) + .await + } + } + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; Ok(Json(courses)) } @@ -543,14 +559,16 @@ pub async fn submit_lesson_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)") + // Detect course completion logic + 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) .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", + "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.course_id) .fetch_one(&pool) @@ -590,12 +608,13 @@ pub struct BadgeResponse { } pub async fn get_user_gamification( - Org(_org_ctx): Org, + Org(org_ctx): Org, State(pool): State, Path(user_id): Path, ) -> Result, 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(org_ctx.id) .fetch_one(&pool) .await .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 FROM user_badges ub 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(org_ctx.id) .fetch_all(&pool) .await .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; @@ -732,15 +752,16 @@ pub async fn get_course_analytics( } pub async fn get_advanced_analytics( - Org(_org_ctx): Org, + Org(org_ctx): Org, State(pool): State, Path(course_id): Path, ) -> Result, StatusCode> { // 1. Cohort Analysis using DB function let cohort_data = sqlx::query_as::<_, common::models::CohortData>( - "SELECT period, student_count as count, completion_rate FROM fn_get_cohort_analytics($1)", + "SELECT period, student_count as count, completion_rate FROM fn_get_cohort_analytics($1, $2)", ) .bind(course_id) + .bind(org_ctx.id) .fetch_all(&pool) .await .map_err(|e| { @@ -750,9 +771,10 @@ pub async fn get_advanced_analytics( // 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)", + "SELECT lesson_id, lesson_title, student_count FROM fn_get_retention_data($1, $2)", ) .bind(course_id) + .bind(org_ctx.id) .fetch_all(&pool) .await .map_err(|e| { diff --git a/shared/common/src/middleware.rs b/shared/common/src/middleware.rs index 065d6a4..2e68c0f 100644 --- a/shared/common/src/middleware.rs +++ b/shared/common/src/middleware.rs @@ -34,7 +34,7 @@ pub async fn org_extractor_middleware( // 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 claims = decode::( + let mut claims = decode::( token, &DecodingKey::from_secret(secret.as_ref()), &Validation::default(), @@ -42,8 +42,24 @@ pub async fn org_extractor_middleware( .map_err(|_| StatusCode::UNAUTHORIZED)? .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. - req.extensions_mut().insert(OrgContext { id: claims.org }); + req.extensions_mut().insert(OrgContext { id: org_id }); req.extensions_mut().insert(claims); Ok(next.run(req).await) diff --git a/validate_auth.sh b/validate_auth.sh index f1ed169..2fb601c 100755 --- a/validate_auth.sh +++ b/validate_auth.sh @@ -16,23 +16,50 @@ else echo "" fi -# 2. Verify New Registration -echo "Testing Registration for newuser@test.com..." -# Clear if exists -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 \ +# 3. Verify Organization Context (Course Scoping) +echo "Testing Course Scoping by Organization..." +# Login to get token +USER_DATA=$(curl -s -X POST http://localhost:3001/auth/login \ -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 - echo "SUCCESS: Registration worked for newuser@test.com" - # Cleanup - 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 \ +if [ "$TOKEN" != "null" ]; then + echo "SUCCESS: Got token for juan.allende@gmail.com" + # Try to list courses + 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 '{"email":"newuser@test.com","password":"password123","full_name":"New User","role":"instructor"}' - echo "" + -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 + echo "FAIL: Could not get token for testing organization context" fi diff --git a/web/experience/src/lib/api.ts b/web/experience/src/lib/api.ts index 7db406c..ab23dd1 100644 --- a/web/experience/src/lib/api.ts +++ b/web/experience/src/lib/api.ts @@ -127,86 +127,85 @@ export interface Module { lessons: Lesson[]; } +const getToken = () => typeof window !== 'undefined' ? localStorage.getItem('token') : null; + +const apiFetch = async (url: string, options: RequestInit = {}, isCMS: boolean = false) => { + const token = getToken(); + 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(); +}; + export const lmsApi = { - async getCatalog(): Promise { - // LMS service uses /catalog for the published courses list - const response = await fetch(`${API_BASE_URL}/catalog`); - if (!response.ok) throw new Error('Failed to fetch catalog'); - return response.json(); + async getCatalog(orgId?: string): Promise { + const query = orgId ? `?organization_id=${orgId}` : ''; + return apiFetch(`/catalog${query}`); }, async getCourseOutline(courseId: string): Promise { - const response = await fetch(`${API_BASE_URL}/courses/${courseId}/outline`); - if (!response.ok) throw new Error('Failed to fetch course outline'); - return response.json(); + return apiFetch(`/courses/${courseId}/outline`); }, async getLesson(id: string): Promise { - return fetch(`${API_BASE_URL}/lessons/${id}`).then(res => res.json()); + return apiFetch(`/lessons/${id}`); }, async register(payload: AuthPayload): Promise { - return fetch(`${API_BASE_URL}/auth/register`, { + return apiFetch('/auth/register', { method: 'POST', - headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload) - }).then(res => res.ok ? res.json() : res.json().then(e => Promise.reject(e))); + }); }, async login(payload: AuthPayload): Promise { - return fetch(`${API_BASE_URL}/auth/login`, { + return apiFetch('/auth/login', { method: 'POST', - headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload) - }).then(res => res.ok ? res.json() : res.json().then(e => Promise.reject(e))); + }); }, async enroll(courseId: string, userId: string): Promise { - return fetch(`${API_BASE_URL}/enroll`, { + return apiFetch('/enroll', { method: 'POST', - headers: { 'Content-Type': 'application/json' }, 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 { - 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 { - const response = await fetch(`${API_BASE_URL}/grades`, { + return apiFetch('/grades', { method: 'POST', - headers: { 'Content-Type': 'application/json' }, 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 { - const response = await fetch(`${API_BASE_URL}/users/${userId}/courses/${courseId}/grades`); - if (!response.ok) throw new Error('Failed to fetch user grades'); - return response.json(); + return apiFetch(`/users/${userId}/courses/${courseId}/grades`); }, 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(); + return apiFetch(`/users/${userId}/gamification`); }, async getLeaderboard(): Promise { - const token = localStorage.getItem('token'); - const response = await fetch(`${API_BASE_URL}/analytics/leaderboard`, { - headers: { 'Authorization': `Bearer ${token}` } - }); - if (!response.ok) throw new Error('Failed to fetch leaderboard'); - return response.json(); + return apiFetch('/analytics/leaderboard'); }, async getBranding(orgId: string): Promise { - const response = await fetch(`${CMS_API_URL}/organizations/${orgId}/branding`); - if (!response.ok) throw new Error('Failed to fetch branding'); - return response.json(); + return apiFetch(`/organizations/${orgId}/branding`, {}, true); } }; diff --git a/web/studio/src/context/AuthContext.tsx b/web/studio/src/context/AuthContext.tsx index 1cafd1f..d4daa9e 100644 --- a/web/studio/src/context/AuthContext.tsx +++ b/web/studio/src/context/AuthContext.tsx @@ -6,8 +6,10 @@ import { User } from '@/lib/api'; interface AuthContextType { user: User | null; token: string | null; + selectedOrgId: string | null; login: (user: User, token: string) => void; logout: () => void; + setOrganizationId: (id: string | null) => void; loading: boolean; } @@ -16,14 +18,19 @@ const AuthContext = createContext(undefined); export function AuthProvider({ children }: { children: React.ReactNode }) { const [user, setUser] = useState(null); const [token, setToken] = useState(null); + const [selectedOrgId, setSelectedOrgId] = useState(null); const [loading, setLoading] = useState(true); useEffect(() => { const savedUser = localStorage.getItem('studio_user'); const savedToken = localStorage.getItem('studio_token'); + const savedOrgId = localStorage.getItem('studio_selected_org_id'); + if (savedUser && savedToken) { - setUser(JSON.parse(savedUser)); + const u = JSON.parse(savedUser); + setUser(u); setToken(savedToken); + setSelectedOrgId(savedOrgId || u.organization_id || null); } setLoading(false); }, []); @@ -31,19 +38,34 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { const login = (newUser: User, newToken: string) => { setUser(newUser); setToken(newToken); + setSelectedOrgId(newUser.organization_id || null); localStorage.setItem('studio_user', JSON.stringify(newUser)); localStorage.setItem('studio_token', newToken); + if (newUser.organization_id) { + localStorage.setItem('studio_selected_org_id', newUser.organization_id); + } }; const logout = () => { setUser(null); setToken(null); + setSelectedOrgId(null); localStorage.removeItem('studio_user'); 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 ( - + {children} ); diff --git a/web/studio/src/lib/api.ts b/web/studio/src/lib/api.ts index a098525..95c5c11 100644 --- a/web/studio/src/lib/api.ts +++ b/web/studio/src/lib/api.ts @@ -191,13 +191,16 @@ export interface CreateWebhookPayload { } 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 token = getToken(); + const selectedOrgId = getSelectedOrgId(); const headers = { 'Content-Type': 'application/json', ...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 => { @@ -273,8 +276,10 @@ export const cmsApi = { formData.append('file', file); const token = getToken(); + const selectedOrgId = getSelectedOrgId(); const headers: Record = { - ...(token ? { 'Authorization': `Bearer ${token}` } : {}) + ...(token ? { 'Authorization': `Bearer ${token}` } : {}), + ...(selectedOrgId ? { 'X-Organization-Id': selectedOrgId } : {}) }; // Note: We don't set 'Content-Type' for multipart/form-data. @@ -295,8 +300,10 @@ export const cmsApi = { const formData = new FormData(); formData.append('file', file); const token = getToken(); + const selectedOrgId = getSelectedOrgId(); const headers: Record = { - ...(token ? { 'Authorization': `Bearer ${token}` } : {}) + ...(token ? { 'Authorization': `Bearer ${token}` } : {}), + ...(selectedOrgId ? { 'X-Organization-Id': selectedOrgId } : {}) }; return fetch(`${API_BASE_URL}/organizations/${id}/logo`, { method: 'POST',