feat: Implement video play count tracking, refactor user update API, add missing CMS delete functions, and update database transaction handling.
This commit is contained in:
@@ -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;
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
use sqlx::{Postgres, Transaction};
|
use sqlx::{Postgres, PgConnection};
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
pub async fn set_session_context(
|
pub async fn set_session_context(
|
||||||
tx: &mut Transaction<'_, Postgres>,
|
conn: &mut PgConnection,
|
||||||
user_id: Option<Uuid>,
|
user_id: Option<Uuid>,
|
||||||
org_id: Option<Uuid>,
|
org_id: Option<Uuid>,
|
||||||
ip_address: Option<String>,
|
ip_address: Option<String>,
|
||||||
@@ -12,31 +12,31 @@ pub async fn set_session_context(
|
|||||||
if let Some(uid) = user_id {
|
if let Some(uid) = user_id {
|
||||||
let _ = sqlx::query("SELECT set_config('app.current_user_id', $1, true)")
|
let _ = sqlx::query("SELECT set_config('app.current_user_id', $1, true)")
|
||||||
.bind(uid.to_string())
|
.bind(uid.to_string())
|
||||||
.execute(&mut **tx)
|
.execute(&mut *conn)
|
||||||
.await?;
|
.await?;
|
||||||
}
|
}
|
||||||
if let Some(oid) = org_id {
|
if let Some(oid) = org_id {
|
||||||
let _ = sqlx::query("SELECT set_config('app.current_org_id', $1, true)")
|
let _ = sqlx::query("SELECT set_config('app.current_org_id', $1, true)")
|
||||||
.bind(oid.to_string())
|
.bind(oid.to_string())
|
||||||
.execute(&mut **tx)
|
.execute(&mut *conn)
|
||||||
.await?;
|
.await?;
|
||||||
}
|
}
|
||||||
if let Some(ip) = ip_address {
|
if let Some(ip) = ip_address {
|
||||||
let _ = sqlx::query("SELECT set_config('app.client_ip', $1, true)")
|
let _ = sqlx::query("SELECT set_config('app.client_ip', $1, true)")
|
||||||
.bind(ip)
|
.bind(ip)
|
||||||
.execute(&mut **tx)
|
.execute(&mut *conn)
|
||||||
.await?;
|
.await?;
|
||||||
}
|
}
|
||||||
if let Some(ua) = user_agent {
|
if let Some(ua) = user_agent {
|
||||||
let _ = sqlx::query("SELECT set_config('app.user_agent', $1, true)")
|
let _ = sqlx::query("SELECT set_config('app.user_agent', $1, true)")
|
||||||
.bind(ua)
|
.bind(ua)
|
||||||
.execute(&mut **tx)
|
.execute(&mut *conn)
|
||||||
.await?;
|
.await?;
|
||||||
}
|
}
|
||||||
if let Some(et) = event_type {
|
if let Some(et) = event_type {
|
||||||
let _ = sqlx::query("SELECT set_config('app.event_type', $1, true)")
|
let _ = sqlx::query("SELECT set_config('app.event_type', $1, true)")
|
||||||
.bind(et)
|
.bind(et)
|
||||||
.execute(&mut **tx)
|
.execute(&mut *conn)
|
||||||
.await?;
|
.await?;
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
|
|||||||
@@ -199,7 +199,7 @@ pub async fn create_course(
|
|||||||
.map(|s| s.to_string());
|
.map(|s| s.to_string());
|
||||||
|
|
||||||
crate::db_util::set_session_context(
|
crate::db_util::set_session_context(
|
||||||
&mut tx,
|
&mut *tx,
|
||||||
Some(instructor_id),
|
Some(instructor_id),
|
||||||
Some(org_ctx.id),
|
Some(org_ctx.id),
|
||||||
ip,
|
ip,
|
||||||
@@ -400,7 +400,7 @@ pub async fn create_module(
|
|||||||
.map(|s| s.to_string());
|
.map(|s| s.to_string());
|
||||||
|
|
||||||
crate::db_util::set_session_context(
|
crate::db_util::set_session_context(
|
||||||
&mut tx,
|
&mut *tx,
|
||||||
Some(claims.sub),
|
Some(claims.sub),
|
||||||
Some(org_ctx.id),
|
Some(org_ctx.id),
|
||||||
ip,
|
ip,
|
||||||
@@ -498,7 +498,7 @@ pub async fn create_lesson(
|
|||||||
.map(|s| s.to_string());
|
.map(|s| s.to_string());
|
||||||
|
|
||||||
crate::db_util::set_session_context(
|
crate::db_util::set_session_context(
|
||||||
&mut tx,
|
&mut *tx,
|
||||||
Some(claims.sub),
|
Some(claims.sub),
|
||||||
Some(org_ctx.id),
|
Some(org_ctx.id),
|
||||||
ip,
|
ip,
|
||||||
@@ -1008,7 +1008,7 @@ pub async fn update_lesson(
|
|||||||
.map(|s| s.to_string());
|
.map(|s| s.to_string());
|
||||||
|
|
||||||
crate::db_util::set_session_context(
|
crate::db_util::set_session_context(
|
||||||
&mut tx,
|
&mut *tx,
|
||||||
Some(claims.sub),
|
Some(claims.sub),
|
||||||
Some(org_ctx.id),
|
Some(org_ctx.id),
|
||||||
ip,
|
ip,
|
||||||
@@ -1231,7 +1231,7 @@ pub async fn reorder_modules(
|
|||||||
.map(|s| s.to_string());
|
.map(|s| s.to_string());
|
||||||
|
|
||||||
crate::db_util::set_session_context(
|
crate::db_util::set_session_context(
|
||||||
&mut tx,
|
&mut *tx,
|
||||||
Some(claims.sub),
|
Some(claims.sub),
|
||||||
Some(org_ctx.id),
|
Some(org_ctx.id),
|
||||||
ip,
|
ip,
|
||||||
@@ -1282,7 +1282,7 @@ pub async fn reorder_lessons(
|
|||||||
.map(|s| s.to_string());
|
.map(|s| s.to_string());
|
||||||
|
|
||||||
crate::db_util::set_session_context(
|
crate::db_util::set_session_context(
|
||||||
&mut tx,
|
&mut *tx,
|
||||||
Some(claims.sub),
|
Some(claims.sub),
|
||||||
Some(org_ctx.id),
|
Some(org_ctx.id),
|
||||||
ip,
|
ip,
|
||||||
@@ -1759,7 +1759,7 @@ pub async fn update_module(
|
|||||||
.map(|s| s.to_string());
|
.map(|s| s.to_string());
|
||||||
|
|
||||||
crate::db_util::set_session_context(
|
crate::db_util::set_session_context(
|
||||||
&mut tx,
|
&mut *tx,
|
||||||
Some(claims.sub),
|
Some(claims.sub),
|
||||||
Some(org_ctx.id),
|
Some(org_ctx.id),
|
||||||
ip,
|
ip,
|
||||||
@@ -1801,8 +1801,8 @@ pub async fn delete_module(
|
|||||||
headers: axum::http::HeaderMap,
|
headers: axum::http::HeaderMap,
|
||||||
Path(id): Path<Uuid>,
|
Path(id): Path<Uuid>,
|
||||||
) -> Result<StatusCode, StatusCode> {
|
) -> Result<StatusCode, StatusCode> {
|
||||||
let mut tx = pool
|
let mut conn = pool
|
||||||
.begin()
|
.acquire()
|
||||||
.await
|
.await
|
||||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||||
|
|
||||||
@@ -1818,7 +1818,7 @@ pub async fn delete_module(
|
|||||||
.map(|s| s.to_string());
|
.map(|s| s.to_string());
|
||||||
|
|
||||||
crate::db_util::set_session_context(
|
crate::db_util::set_session_context(
|
||||||
&mut tx,
|
&mut conn,
|
||||||
Some(claims.sub),
|
Some(claims.sub),
|
||||||
Some(org_ctx.id),
|
Some(org_ctx.id),
|
||||||
ip,
|
ip,
|
||||||
@@ -1831,7 +1831,7 @@ pub async fn delete_module(
|
|||||||
let success = sqlx::query_scalar::<_, bool>("SELECT fn_delete_module($1, $2)")
|
let success = sqlx::query_scalar::<_, bool>("SELECT fn_delete_module($1, $2)")
|
||||||
.bind(id)
|
.bind(id)
|
||||||
.bind(org_ctx.id)
|
.bind(org_ctx.id)
|
||||||
.fetch_one(&mut *tx)
|
.fetch_one(&mut *conn)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| {
|
.map_err(|e| {
|
||||||
tracing::error!("Delete module failed: {}", e);
|
tracing::error!("Delete module failed: {}", e);
|
||||||
@@ -1842,10 +1842,6 @@ pub async fn delete_module(
|
|||||||
return Err(StatusCode::NOT_FOUND);
|
return Err(StatusCode::NOT_FOUND);
|
||||||
}
|
}
|
||||||
|
|
||||||
tx.commit()
|
|
||||||
.await
|
|
||||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
|
||||||
|
|
||||||
Ok(StatusCode::OK)
|
Ok(StatusCode::OK)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1860,8 +1856,8 @@ pub async fn delete_lesson(
|
|||||||
return Err(StatusCode::FORBIDDEN);
|
return Err(StatusCode::FORBIDDEN);
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut tx = pool
|
let mut conn = pool
|
||||||
.begin()
|
.acquire()
|
||||||
.await
|
.await
|
||||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||||
|
|
||||||
@@ -1877,7 +1873,7 @@ pub async fn delete_lesson(
|
|||||||
.map(|s| s.to_string());
|
.map(|s| s.to_string());
|
||||||
|
|
||||||
crate::db_util::set_session_context(
|
crate::db_util::set_session_context(
|
||||||
&mut tx,
|
&mut conn,
|
||||||
Some(claims.sub),
|
Some(claims.sub),
|
||||||
Some(org_ctx.id),
|
Some(org_ctx.id),
|
||||||
ip,
|
ip,
|
||||||
@@ -1890,7 +1886,7 @@ pub async fn delete_lesson(
|
|||||||
let success = sqlx::query_scalar::<_, bool>("SELECT fn_delete_lesson($1, $2)")
|
let success = sqlx::query_scalar::<_, bool>("SELECT fn_delete_lesson($1, $2)")
|
||||||
.bind(id)
|
.bind(id)
|
||||||
.bind(org_ctx.id)
|
.bind(org_ctx.id)
|
||||||
.fetch_one(&mut *tx)
|
.fetch_one(&mut *conn)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| {
|
.map_err(|e| {
|
||||||
tracing::error!("Delete lesson failed: {}", e);
|
tracing::error!("Delete lesson failed: {}", e);
|
||||||
@@ -1901,10 +1897,6 @@ pub async fn delete_lesson(
|
|||||||
return Err(StatusCode::NOT_FOUND);
|
return Err(StatusCode::NOT_FOUND);
|
||||||
}
|
}
|
||||||
|
|
||||||
tx.commit()
|
|
||||||
.await
|
|
||||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
|
||||||
|
|
||||||
Ok(StatusCode::OK)
|
Ok(StatusCode::OK)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -159,6 +159,33 @@ export default function LessonPlayerPage({ params }: { params: { id: string, les
|
|||||||
media_type={block.media_type || 'video'}
|
media_type={block.media_type || 'video'}
|
||||||
config={block.config}
|
config={block.config}
|
||||||
onTimeUpdate={setCurrentTime}
|
onTimeUpdate={setCurrentTime}
|
||||||
|
initialPlayCount={
|
||||||
|
userGrade?.metadata?.play_counts
|
||||||
|
? (userGrade.metadata.play_counts as Record<string, number>)[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<string, number>) || {};
|
||||||
|
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' && (
|
{block.type === 'quiz' && (
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
import { useState, useEffect } from "react";
|
import { useState, useEffect } from "react";
|
||||||
import { useAuth } from "@/context/AuthContext";
|
import { useAuth } from "@/context/AuthContext";
|
||||||
import { lmsApi } from "@/lib/api";
|
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() {
|
export default function ProfilePage() {
|
||||||
const { user, logout } = useAuth();
|
const { user, logout } = useAuth();
|
||||||
|
|||||||
@@ -11,11 +11,13 @@ interface MediaPlayerProps {
|
|||||||
config?: {
|
config?: {
|
||||||
maxPlays?: number;
|
maxPlays?: number;
|
||||||
};
|
};
|
||||||
|
initialPlayCount?: number;
|
||||||
onTimeUpdate?: (time: number) => void;
|
onTimeUpdate?: (time: number) => void;
|
||||||
|
onPlay?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function MediaPlayer({ id, title, url, media_type, config, onTimeUpdate }: MediaPlayerProps) {
|
export default function MediaPlayer({ id, title, url, media_type, config, initialPlayCount, onTimeUpdate, onPlay }: MediaPlayerProps) {
|
||||||
const [playCount, setPlayCount] = useState(0);
|
const [playCount, setPlayCount] = useState(initialPlayCount || 0);
|
||||||
const [hasStarted, setHasStarted] = useState(false);
|
const [hasStarted, setHasStarted] = useState(false);
|
||||||
const [locked, setLocked] = useState(false);
|
const [locked, setLocked] = useState(false);
|
||||||
|
|
||||||
@@ -44,6 +46,7 @@ export default function MediaPlayer({ id, title, url, media_type, config, onTime
|
|||||||
if (!hasStarted) {
|
if (!hasStarted) {
|
||||||
setPlayCount(prev => prev + 1);
|
setPlayCount(prev => prev + 1);
|
||||||
setHasStarted(true);
|
setHasStarted(true);
|
||||||
|
if (onPlay) onPlay();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -190,10 +190,10 @@ export const lmsApi = {
|
|||||||
return apiFetch(`/enrollments/${userId}`);
|
return apiFetch(`/enrollments/${userId}`);
|
||||||
},
|
},
|
||||||
|
|
||||||
async submitScore(userId: string, courseId: string, lessonId: string, score: number): Promise<UserGrade> {
|
async submitScore(userId: string, course_id: string, lessonId: string, score: number, metadata: Record<string, unknown> = {}): Promise<UserGrade> {
|
||||||
return apiFetch('/grades', {
|
return apiFetch('/grades', {
|
||||||
method: 'POST',
|
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 })
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ export default function UsersPage() {
|
|||||||
|
|
||||||
const handleUpdateUser = async (userId: string, role: string, orgId: string) => {
|
const handleUpdateUser = async (userId: string, role: string, orgId: string) => {
|
||||||
try {
|
try {
|
||||||
await cmsApi.updateUser(userId, role, orgId);
|
await cmsApi.updateUser(userId, { role, organization_id: orgId });
|
||||||
loadData();
|
loadData();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to update user', error);
|
console.error('Failed to update user', error);
|
||||||
|
|||||||
@@ -3,10 +3,10 @@
|
|||||||
import { useState, useEffect } from "react";
|
import { useState, useEffect } from "react";
|
||||||
import { useAuth } from "@/context/AuthContext";
|
import { useAuth } from "@/context/AuthContext";
|
||||||
import { cmsApi } from "@/lib/api";
|
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() {
|
export default function ProfilePage() {
|
||||||
const { user, token, logout } = useAuth();
|
const { user, logout } = useAuth();
|
||||||
const [fullName, setFullName] = useState(user?.full_name || "");
|
const [fullName, setFullName] = useState(user?.full_name || "");
|
||||||
const [email, setEmail] = useState(user?.email || "");
|
const [email, setEmail] = useState(user?.email || "");
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
|
|||||||
@@ -266,7 +266,7 @@ export const cmsApi = {
|
|||||||
|
|
||||||
// Users
|
// Users
|
||||||
getAllUsers: (): Promise<User[]> => apiFetch('/users'),
|
getAllUsers: (): Promise<User[]> => apiFetch('/users'),
|
||||||
updateUser: (id: string, role: string, organization_id: string): Promise<void> => apiFetch(`/users/${id}`, { method: 'PUT', body: JSON.stringify({ role, organization_id }) }),
|
updateUser: (id: string, payload: { role?: string, organization_id?: string, full_name?: string }): Promise<void> => apiFetch(`/users/${id}`, { method: 'PUT', body: JSON.stringify(payload) }),
|
||||||
|
|
||||||
// Webhooks
|
// Webhooks
|
||||||
getWebhooks: (): Promise<Webhook[]> => apiFetch('/webhooks'),
|
getWebhooks: (): Promise<Webhook[]> => apiFetch('/webhooks'),
|
||||||
|
|||||||
Reference in New Issue
Block a user