From 42976236b346cb5d2288706d3e8c65fb769d1f3b Mon Sep 17 00:00:00 2001 From: Nurfog Date: Fri, 16 Jan 2026 13:43:58 -0300 Subject: [PATCH] feat: Implement video play count tracking, refactor user update API, add missing CMS delete functions, and update database transaction handling. --- .../20260116000011_add_delete_functions.sql | 32 +++++++++++++ services/cms-service/src/db_util.rs | 14 +++--- services/cms-service/src/handlers.rs | 38 +++++++--------- .../20260116000002_fix_grading_logic.sql | 45 +++++++++++++++++++ .../courses/[id]/lessons/[lessonId]/page.tsx | 27 +++++++++++ web/experience/src/app/profile/page.tsx | 2 +- .../src/components/blocks/MediaPlayer.tsx | 7 ++- web/experience/src/lib/api.ts | 4 +- web/studio/src/app/admin/users/page.tsx | 2 +- web/studio/src/app/profile/page.tsx | 4 +- web/studio/src/lib/api.ts | 2 +- 11 files changed, 138 insertions(+), 39 deletions(-) create mode 100644 services/cms-service/migrations/20260116000011_add_delete_functions.sql create mode 100644 services/lms-service/migrations/20260116000002_fix_grading_logic.sql diff --git a/services/cms-service/migrations/20260116000011_add_delete_functions.sql b/services/cms-service/migrations/20260116000011_add_delete_functions.sql new file mode 100644 index 0000000..6b19805 --- /dev/null +++ b/services/cms-service/migrations/20260116000011_add_delete_functions.sql @@ -0,0 +1,32 @@ +-- Migration: Add missing delete functions +-- Adds fn_delete_module and fn_delete_lesson which were missing from the CRUD migration + +CREATE OR REPLACE FUNCTION fn_delete_module( + p_id UUID, + p_organization_id UUID +) RETURNS BOOLEAN AS $$ +DECLARE + v_deleted_count INTEGER; +BEGIN + DELETE FROM modules + 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; + +CREATE OR REPLACE FUNCTION fn_delete_lesson( + p_id UUID, + p_organization_id UUID +) RETURNS BOOLEAN AS $$ +DECLARE + v_deleted_count INTEGER; +BEGIN + DELETE FROM lessons + 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; diff --git a/services/cms-service/src/db_util.rs b/services/cms-service/src/db_util.rs index 5fa99c6..e21737d 100644 --- a/services/cms-service/src/db_util.rs +++ b/services/cms-service/src/db_util.rs @@ -1,8 +1,8 @@ -use sqlx::{Postgres, Transaction}; +use sqlx::{Postgres, PgConnection}; use uuid::Uuid; pub async fn set_session_context( - tx: &mut Transaction<'_, Postgres>, + conn: &mut PgConnection, user_id: Option, org_id: Option, ip_address: Option, @@ -12,31 +12,31 @@ pub async fn set_session_context( 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) + .execute(&mut *conn) .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) + .execute(&mut *conn) .await?; } if let Some(ip) = ip_address { let _ = sqlx::query("SELECT set_config('app.client_ip', $1, true)") .bind(ip) - .execute(&mut **tx) + .execute(&mut *conn) .await?; } if let Some(ua) = user_agent { let _ = sqlx::query("SELECT set_config('app.user_agent', $1, true)") .bind(ua) - .execute(&mut **tx) + .execute(&mut *conn) .await?; } if let Some(et) = event_type { let _ = sqlx::query("SELECT set_config('app.event_type', $1, true)") .bind(et) - .execute(&mut **tx) + .execute(&mut *conn) .await?; } Ok(()) diff --git a/services/cms-service/src/handlers.rs b/services/cms-service/src/handlers.rs index cca5e06..83f41a5 100644 --- a/services/cms-service/src/handlers.rs +++ b/services/cms-service/src/handlers.rs @@ -199,7 +199,7 @@ pub async fn create_course( .map(|s| s.to_string()); crate::db_util::set_session_context( - &mut tx, + &mut *tx, Some(instructor_id), Some(org_ctx.id), ip, @@ -400,7 +400,7 @@ pub async fn create_module( .map(|s| s.to_string()); crate::db_util::set_session_context( - &mut tx, + &mut *tx, Some(claims.sub), Some(org_ctx.id), ip, @@ -498,7 +498,7 @@ pub async fn create_lesson( .map(|s| s.to_string()); crate::db_util::set_session_context( - &mut tx, + &mut *tx, Some(claims.sub), Some(org_ctx.id), ip, @@ -1008,7 +1008,7 @@ pub async fn update_lesson( .map(|s| s.to_string()); crate::db_util::set_session_context( - &mut tx, + &mut *tx, Some(claims.sub), Some(org_ctx.id), ip, @@ -1231,7 +1231,7 @@ pub async fn reorder_modules( .map(|s| s.to_string()); crate::db_util::set_session_context( - &mut tx, + &mut *tx, Some(claims.sub), Some(org_ctx.id), ip, @@ -1282,7 +1282,7 @@ pub async fn reorder_lessons( .map(|s| s.to_string()); crate::db_util::set_session_context( - &mut tx, + &mut *tx, Some(claims.sub), Some(org_ctx.id), ip, @@ -1759,7 +1759,7 @@ pub async fn update_module( .map(|s| s.to_string()); crate::db_util::set_session_context( - &mut tx, + &mut *tx, Some(claims.sub), Some(org_ctx.id), ip, @@ -1801,8 +1801,8 @@ pub async fn delete_module( headers: axum::http::HeaderMap, Path(id): Path, ) -> Result { - let mut tx = pool - .begin() + let mut conn = pool + .acquire() .await .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; @@ -1818,7 +1818,7 @@ pub async fn delete_module( .map(|s| s.to_string()); crate::db_util::set_session_context( - &mut tx, + &mut conn, Some(claims.sub), Some(org_ctx.id), ip, @@ -1831,7 +1831,7 @@ pub async fn delete_module( let success = sqlx::query_scalar::<_, bool>("SELECT fn_delete_module($1, $2)") .bind(id) .bind(org_ctx.id) - .fetch_one(&mut *tx) + .fetch_one(&mut *conn) .await .map_err(|e| { tracing::error!("Delete module failed: {}", e); @@ -1842,10 +1842,6 @@ pub async fn delete_module( return Err(StatusCode::NOT_FOUND); } - tx.commit() - .await - .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; - Ok(StatusCode::OK) } @@ -1860,8 +1856,8 @@ pub async fn delete_lesson( return Err(StatusCode::FORBIDDEN); } - let mut tx = pool - .begin() + let mut conn = pool + .acquire() .await .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; @@ -1877,7 +1873,7 @@ pub async fn delete_lesson( .map(|s| s.to_string()); crate::db_util::set_session_context( - &mut tx, + &mut conn, Some(claims.sub), Some(org_ctx.id), ip, @@ -1890,7 +1886,7 @@ pub async fn delete_lesson( let success = sqlx::query_scalar::<_, bool>("SELECT fn_delete_lesson($1, $2)") .bind(id) .bind(org_ctx.id) - .fetch_one(&mut *tx) + .fetch_one(&mut *conn) .await .map_err(|e| { tracing::error!("Delete lesson failed: {}", e); @@ -1901,10 +1897,6 @@ pub async fn delete_lesson( return Err(StatusCode::NOT_FOUND); } - tx.commit() - .await - .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; - Ok(StatusCode::OK) } diff --git a/services/lms-service/migrations/20260116000002_fix_grading_logic.sql b/services/lms-service/migrations/20260116000002_fix_grading_logic.sql new file mode 100644 index 0000000..174d1b7 --- /dev/null +++ b/services/lms-service/migrations/20260116000002_fix_grading_logic.sql @@ -0,0 +1,45 @@ +-- Migration: Fix Grading Logic for Multi-tenancy +-- Corrects fn_upsert_user_grade to properly handle organization_id in user_badges + +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 AND organization_id = p_organization_id) + LOOP + INSERT INTO user_badges (user_id, badge_id, organization_id) + VALUES (p_user_id, v_badge_id, p_organization_id) + ON CONFLICT (user_id, badge_id) DO NOTHING; + END LOOP; + + RETURN NEXT v_grade; +END; +$$ LANGUAGE plpgsql; diff --git a/web/experience/src/app/courses/[id]/lessons/[lessonId]/page.tsx b/web/experience/src/app/courses/[id]/lessons/[lessonId]/page.tsx index af870f6..c928f2f 100644 --- a/web/experience/src/app/courses/[id]/lessons/[lessonId]/page.tsx +++ b/web/experience/src/app/courses/[id]/lessons/[lessonId]/page.tsx @@ -159,6 +159,33 @@ export default function LessonPlayerPage({ params }: { params: { id: string, les media_type={block.media_type || 'video'} config={block.config} onTimeUpdate={setCurrentTime} + initialPlayCount={ + userGrade?.metadata?.play_counts + ? (userGrade.metadata.play_counts as Record)[block.id] || 0 + : 0 + } + onPlay={async () => { + if (user && lesson.max_attempts && (!userGrade || userGrade.attempts_count < lesson.max_attempts)) { + const currentPlayCounts = (userGrade?.metadata?.play_counts as Record) || {}; + const newPlayCounts = { + ...currentPlayCounts, + [block.id]: (currentPlayCounts[block.id] || 0) + 1 + }; + + try { + const res = await lmsApi.submitScore( + user.id, + params.id, + params.lessonId, + userGrade?.score || 0, + { ...userGrade?.metadata, play_counts: newPlayCounts } + ); + setUserGrade(res); + } catch (err) { + console.error("Failed to persist play count", err); + } + } + }} /> )} {block.type === 'quiz' && ( diff --git a/web/experience/src/app/profile/page.tsx b/web/experience/src/app/profile/page.tsx index b859635..bbe75b8 100644 --- a/web/experience/src/app/profile/page.tsx +++ b/web/experience/src/app/profile/page.tsx @@ -3,7 +3,7 @@ import { useState, useEffect } from "react"; import { useAuth } from "@/context/AuthContext"; import { lmsApi } from "@/lib/api"; -import { User, Save, Shield, Mail, User as UserIcon, Building, Trophy, Flame } from "lucide-react"; +import { Save, Shield, Mail, User as UserIcon, Building, Trophy, Flame } from "lucide-react"; export default function ProfilePage() { const { user, logout } = useAuth(); diff --git a/web/experience/src/components/blocks/MediaPlayer.tsx b/web/experience/src/components/blocks/MediaPlayer.tsx index bcdd48f..ade8c53 100644 --- a/web/experience/src/components/blocks/MediaPlayer.tsx +++ b/web/experience/src/components/blocks/MediaPlayer.tsx @@ -11,11 +11,13 @@ interface MediaPlayerProps { config?: { maxPlays?: number; }; + initialPlayCount?: number; onTimeUpdate?: (time: number) => void; + onPlay?: () => void; } -export default function MediaPlayer({ id, title, url, media_type, config, onTimeUpdate }: MediaPlayerProps) { - const [playCount, setPlayCount] = useState(0); +export default function MediaPlayer({ id, title, url, media_type, config, initialPlayCount, onTimeUpdate, onPlay }: MediaPlayerProps) { + const [playCount, setPlayCount] = useState(initialPlayCount || 0); const [hasStarted, setHasStarted] = useState(false); const [locked, setLocked] = useState(false); @@ -44,6 +46,7 @@ export default function MediaPlayer({ id, title, url, media_type, config, onTime if (!hasStarted) { setPlayCount(prev => prev + 1); setHasStarted(true); + if (onPlay) onPlay(); } }; diff --git a/web/experience/src/lib/api.ts b/web/experience/src/lib/api.ts index 28fef33..9bb47a0 100644 --- a/web/experience/src/lib/api.ts +++ b/web/experience/src/lib/api.ts @@ -190,10 +190,10 @@ export const lmsApi = { return apiFetch(`/enrollments/${userId}`); }, - async submitScore(userId: string, courseId: string, lessonId: string, score: number): Promise { + async submitScore(userId: string, course_id: string, lessonId: string, score: number, metadata: Record = {}): Promise { return apiFetch('/grades', { method: 'POST', - body: JSON.stringify({ user_id: userId, course_id: courseId, lesson_id: lessonId, score }) + body: JSON.stringify({ user_id: userId, course_id, lesson_id: lessonId, score, metadata }) }); }, diff --git a/web/studio/src/app/admin/users/page.tsx b/web/studio/src/app/admin/users/page.tsx index 7ccb2e3..182c20c 100644 --- a/web/studio/src/app/admin/users/page.tsx +++ b/web/studio/src/app/admin/users/page.tsx @@ -34,7 +34,7 @@ export default function UsersPage() { const handleUpdateUser = async (userId: string, role: string, orgId: string) => { try { - await cmsApi.updateUser(userId, role, orgId); + await cmsApi.updateUser(userId, { role, organization_id: orgId }); loadData(); } catch (error) { console.error('Failed to update user', error); diff --git a/web/studio/src/app/profile/page.tsx b/web/studio/src/app/profile/page.tsx index 7d02742..9d10152 100644 --- a/web/studio/src/app/profile/page.tsx +++ b/web/studio/src/app/profile/page.tsx @@ -3,10 +3,10 @@ import { useState, useEffect } from "react"; import { useAuth } from "@/context/AuthContext"; import { cmsApi } from "@/lib/api"; -import { User, Save, Shield, Mail, User as UserIcon, Building } from "lucide-react"; +import { Save, Shield, Mail, User as UserIcon, Building } from "lucide-react"; export default function ProfilePage() { - const { user, token, logout } = useAuth(); + const { user, logout } = useAuth(); const [fullName, setFullName] = useState(user?.full_name || ""); const [email, setEmail] = useState(user?.email || ""); const [saving, setSaving] = useState(false); diff --git a/web/studio/src/lib/api.ts b/web/studio/src/lib/api.ts index cbed0ad..c6a33c7 100644 --- a/web/studio/src/lib/api.ts +++ b/web/studio/src/lib/api.ts @@ -266,7 +266,7 @@ export const cmsApi = { // Users getAllUsers: (): Promise => apiFetch('/users'), - updateUser: (id: string, role: string, organization_id: string): Promise => apiFetch(`/users/${id}`, { method: 'PUT', body: JSON.stringify({ role, organization_id }) }), + updateUser: (id: string, payload: { role?: string, organization_id?: string, full_name?: string }): Promise => apiFetch(`/users/${id}`, { method: 'PUT', body: JSON.stringify(payload) }), // Webhooks getWebhooks: (): Promise => apiFetch('/webhooks'),