feat: improve collaborative document handling and token management
This commit is contained in:
@@ -4884,7 +4884,16 @@ pub async fn get_lesson_collaborative_doc(
|
|||||||
.bind(org_ctx.id)
|
.bind(org_ctx.id)
|
||||||
.fetch_optional(&pool)
|
.fetch_optional(&pool)
|
||||||
.await
|
.await
|
||||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
.map_err(|e| {
|
||||||
|
tracing::error!(
|
||||||
|
"get_lesson_collaborative_doc: failed to fetch doc for lesson {} org {}: {}",
|
||||||
|
id,
|
||||||
|
org_ctx.id,
|
||||||
|
e
|
||||||
|
);
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR
|
||||||
|
})
|
||||||
|
.unwrap_or(None);
|
||||||
|
|
||||||
let doc = row.unwrap_or(DocRow {
|
let doc = row.unwrap_or(DocRow {
|
||||||
content: String::new(),
|
content: String::new(),
|
||||||
@@ -4967,9 +4976,16 @@ pub async fn update_lesson_collaborative_doc(
|
|||||||
if existing.is_none() && payload.base_revision == 0 {
|
if existing.is_none() && payload.base_revision == 0 {
|
||||||
// Primer guardado
|
// Primer guardado
|
||||||
let course_id = sqlx::query_scalar::<_, Uuid>(
|
let course_id = sqlx::query_scalar::<_, Uuid>(
|
||||||
"SELECT course_id FROM lessons WHERE id = $1",
|
r#"
|
||||||
|
SELECT m.course_id
|
||||||
|
FROM lessons l
|
||||||
|
JOIN modules m ON m.id = l.module_id
|
||||||
|
JOIN courses c ON c.id = m.course_id
|
||||||
|
WHERE l.id = $1 AND c.organization_id = $2
|
||||||
|
"#,
|
||||||
)
|
)
|
||||||
.bind(id)
|
.bind(id)
|
||||||
|
.bind(org_ctx.id)
|
||||||
.fetch_one(&pool)
|
.fetch_one(&pool)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||||
|
|||||||
@@ -144,8 +144,10 @@ async fn main() {
|
|||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
let mut governor_conf = GovernorConfigBuilder::default()
|
let mut governor_conf = GovernorConfigBuilder::default()
|
||||||
.const_per_second(10)
|
// La vista de lecciones abre varias solicitudes concurrentes + 2 streams SSE.
|
||||||
.const_burst_size(50)
|
// Con límites muy bajos se generan 429 al navegar entre lecciones.
|
||||||
|
.const_per_second(80)
|
||||||
|
.const_burst_size(240)
|
||||||
.key_extractor(SmartIpKeyExtractor);
|
.key_extractor(SmartIpKeyExtractor);
|
||||||
|
|
||||||
let governor_conf = Arc::new(governor_conf.finish().unwrap());
|
let governor_conf = Arc::new(governor_conf.finish().unwrap());
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ export default function LessonPlayerPage({ params }: { params: { id: string, les
|
|||||||
const [allGrades, setAllGrades] = useState<UserGrade[]>([]);
|
const [allGrades, setAllGrades] = useState<UserGrade[]>([]);
|
||||||
const [isBookmarked, setIsBookmarked] = useState(false);
|
const [isBookmarked, setIsBookmarked] = useState(false);
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
|
const userId = user?.id ?? null;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const fetchAll = async () => {
|
const fetchAll = async () => {
|
||||||
@@ -55,9 +56,9 @@ export default function LessonPlayerPage({ params }: { params: { id: string, les
|
|||||||
setLesson(lessonData);
|
setLesson(lessonData);
|
||||||
setCourse({ ...outlineData.course, modules: outlineData.modules });
|
setCourse({ ...outlineData.course, modules: outlineData.modules });
|
||||||
|
|
||||||
if (user) {
|
if (userId) {
|
||||||
const [grades, bookmarks] = await Promise.all([
|
const [grades, bookmarks] = await Promise.all([
|
||||||
lmsApi.getUserGrades(user.id, params.id),
|
lmsApi.getUserGrades(userId, params.id),
|
||||||
lmsApi.getBookmarks(params.id)
|
lmsApi.getBookmarks(params.id)
|
||||||
]);
|
]);
|
||||||
setAllGrades(grades);
|
setAllGrades(grades);
|
||||||
@@ -72,7 +73,7 @@ export default function LessonPlayerPage({ params }: { params: { id: string, les
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
fetchAll();
|
fetchAll();
|
||||||
}, [params.id, params.lessonId, user]);
|
}, [params.id, params.lessonId, userId]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (typeof window === "undefined") return;
|
if (typeof window === "undefined") return;
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
import { lmsApi, getLmsApiUrl, CollaborativeDoc, UpdateCollaborativeDocResponse } from "@/lib/api";
|
import { lmsApi, getLmsApiUrl, getToken, CollaborativeDoc, UpdateCollaborativeDocResponse } from "@/lib/api";
|
||||||
import {
|
import {
|
||||||
AlertTriangle,
|
AlertTriangle,
|
||||||
CheckCircle,
|
CheckCircle,
|
||||||
@@ -50,11 +50,9 @@ export default function CollaborativeDocEditor({ lessonId }: Props) {
|
|||||||
|
|
||||||
// SSE: escuchar cambios remotos
|
// SSE: escuchar cambios remotos
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const token = typeof window !== "undefined"
|
const token = getToken() || "";
|
||||||
? localStorage.getItem("lms_token") ?? sessionStorage.getItem("lms_token") ?? ""
|
|
||||||
: "";
|
|
||||||
const baseUrl = getLmsApiUrl();
|
const baseUrl = getLmsApiUrl();
|
||||||
const url = `${baseUrl}/lessons/${lessonId}/collaborative-doc/stream?preview_token=${encodeURIComponent(token)}`;
|
const url = `${baseUrl}/lessons/${lessonId}/collaborative-doc/stream${token ? `?preview_token=${encodeURIComponent(token)}` : ""}`;
|
||||||
|
|
||||||
const es = new EventSource(url);
|
const es = new EventSource(url);
|
||||||
sseRef.current = es;
|
sseRef.current = es;
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState, type PointerEvent } from "react";
|
import { useCallback, useEffect, useMemo, useRef, useState, type PointerEvent } from "react";
|
||||||
import { lmsApi, getLmsApiUrl, CollaborativeCanvasState } from "@/lib/api";
|
import { lmsApi, getLmsApiUrl, getToken, CollaborativeCanvasState } from "@/lib/api";
|
||||||
import { AlertTriangle, CheckCircle, Loader2, RefreshCw, Save, Trash2 } from "lucide-react";
|
import { AlertTriangle, CheckCircle, Loader2, RefreshCw, Save, Trash2 } from "lucide-react";
|
||||||
|
|
||||||
type ConflictInfo = {
|
type ConflictInfo = {
|
||||||
@@ -61,6 +61,16 @@ export default function CollaborativeWhiteboard({ lessonId }: Props) {
|
|||||||
const [conflict, setConflict] = useState<ConflictInfo | null>(null);
|
const [conflict, setConflict] = useState<ConflictInfo | null>(null);
|
||||||
|
|
||||||
const isDrawing = useRef(false);
|
const isDrawing = useRef(false);
|
||||||
|
const dirtyRef = useRef(false);
|
||||||
|
const revisionRef = useRef(0);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
dirtyRef.current = dirty;
|
||||||
|
}, [dirty]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
revisionRef.current = revision;
|
||||||
|
}, [revision]);
|
||||||
|
|
||||||
const allStrokes = useMemo(() => {
|
const allStrokes = useMemo(() => {
|
||||||
return draftStroke ? [...strokes, draftStroke] : strokes;
|
return draftStroke ? [...strokes, draftStroke] : strokes;
|
||||||
@@ -219,24 +229,20 @@ export default function CollaborativeWhiteboard({ lessonId }: Props) {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const base = getLmsApiUrl();
|
const base = getLmsApiUrl();
|
||||||
// getToken no es exportada; leemos directamente de sessionStorage/localStorage
|
const token = getToken() || "";
|
||||||
const token =
|
|
||||||
(typeof window !== "undefined" &&
|
|
||||||
(sessionStorage.getItem("preview_token") || localStorage.getItem("experience_token"))) ||
|
|
||||||
"";
|
|
||||||
|
|
||||||
const url = `${base}/lessons/${lessonId}/collaborative-canvas/stream${token ? `?preview_token=${encodeURIComponent(token)}` : ""}`;
|
const url = `${base}/lessons/${lessonId}/collaborative-canvas/stream${token ? `?preview_token=${encodeURIComponent(token)}` : ""}`;
|
||||||
const es = new EventSource(url);
|
const es = new EventSource(url);
|
||||||
|
|
||||||
es.onmessage = (ev) => {
|
es.onmessage = (ev) => {
|
||||||
if (dirty || isDrawing.current) return;
|
if (dirtyRef.current || isDrawing.current) return;
|
||||||
try {
|
try {
|
||||||
const data = JSON.parse(ev.data as string) as {
|
const data = JSON.parse(ev.data as string) as {
|
||||||
revision: number;
|
revision: number;
|
||||||
canvas_state: CollaborativeCanvasState;
|
canvas_state: CollaborativeCanvasState;
|
||||||
updated_at: string;
|
updated_at: string;
|
||||||
};
|
};
|
||||||
if (data.revision !== revision) {
|
if (data.revision !== revisionRef.current) {
|
||||||
setStrokes(toStrokeArray(data.canvas_state || DEFAULT_CANVAS));
|
setStrokes(toStrokeArray(data.canvas_state || DEFAULT_CANVAS));
|
||||||
setRevision(data.revision);
|
setRevision(data.revision);
|
||||||
setLastSavedAt(data.updated_at);
|
setLastSavedAt(data.updated_at);
|
||||||
@@ -253,7 +259,7 @@ export default function CollaborativeWhiteboard({ lessonId }: Props) {
|
|||||||
return () => {
|
return () => {
|
||||||
es.close();
|
es.close();
|
||||||
};
|
};
|
||||||
}, [dirty, lessonId, revision]);
|
}, [lessonId]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="space-y-4 rounded-3xl border border-black/10 dark:border-white/10 bg-white dark:bg-black/20 p-5">
|
<section className="space-y-4 rounded-3xl border border-black/10 dark:border-white/10 bg-white dark:bg-black/20 p-5">
|
||||||
|
|||||||
@@ -586,7 +586,7 @@ export interface UpdateAnnouncementPayload {
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
const getToken = () => {
|
export const getToken = () => {
|
||||||
if (typeof window === 'undefined') return null;
|
if (typeof window === 'undefined') return null;
|
||||||
|
|
||||||
// Check for preview token in URL
|
// Check for preview token in URL
|
||||||
|
|||||||
Reference in New Issue
Block a user