feat: persist learner progress and comments
This commit is contained in:
@@ -10,7 +10,7 @@ The project now has a small API and a PostgreSQL schema. Video delivery is provi
|
||||
4. Apply the versioned schema with `npm run db:migrate`.
|
||||
5. Run the API with `npm run dev:api` and the frontend with `npm run dev`.
|
||||
|
||||
The API health endpoint is available at `http://localhost:3001/api/v1/health`. The readiness endpoint verifies PostgreSQL at `/api/v1/ready`.
|
||||
The API health endpoint is available at `http://localhost:3001/api/v1/health` and verifies its PostgreSQL connection. `/api/v1/ready` is kept as an equivalent readiness endpoint for infrastructure checks.
|
||||
|
||||
## Current API
|
||||
|
||||
|
||||
@@ -31,7 +31,7 @@ Optional variables:
|
||||
|
||||
1. Push to `main` and wait for Gitea Actions to publish `gitea.blyzer.com.br/blyzer/compor-academy-api:latest` and `gitea.blyzer.com.br/blyzer/compor-academy-web:latest`.
|
||||
2. Ensure the Portainer endpoint can pull from the Gitea Container Registry. If the images are private, add Gitea registry credentials to the endpoint/stack deployment configuration.
|
||||
3. Deploy the stack with a temporary `WEB_PORT` and verify `/api/v1/health` through the public domain.
|
||||
3. Deploy the stack with a temporary `WEB_PORT` and verify `/api/v1/health` through the public domain. A healthy response is `{"status":"ok","database":"connected"}`; Portainer also runs this check automatically for the API service.
|
||||
4. Create the production administrator using the API container's console and `npm run db:bootstrap-admin`, with the `BOOTSTRAP_ADMIN_*` variables supplied only for that one command.
|
||||
5. Place the web service behind HTTPS, normally through your existing reverse proxy (Traefik, Nginx Proxy Manager, or Cloudflare Tunnel), and set `FRONTEND_ORIGIN` to that HTTPS address.
|
||||
6. Back up the `compor_postgres_data` volume before updates.
|
||||
|
||||
@@ -19,7 +19,7 @@ import {
|
||||
} from 'lucide-react';
|
||||
import { Course, Comment, Lesson, Attachment } from '../types';
|
||||
import { useAuth } from '../context/AuthContext';
|
||||
import { getComments, saveComment, saveProgress, incrementViews } from '../services/db';
|
||||
import { getComments, getCompletedLessonIds, saveComment, saveLessonCompletion, incrementViews } from '../services/db';
|
||||
import { LoginModal } from './LoginModal';
|
||||
import { MaterialCard } from './MaterialCard';
|
||||
|
||||
@@ -32,7 +32,6 @@ export const CoursePlayerModal: React.FC<CoursePlayerModalProps> = ({ course, on
|
||||
const { user } = useAuth();
|
||||
const [comments, setComments] = useState<Comment[]>([]);
|
||||
const [newComment, setNewComment] = useState('');
|
||||
const [currentProgress, setCurrentProgress] = useState(0);
|
||||
const [currentLesson, setCurrentLesson] = useState<Lesson | null>(null);
|
||||
const [completedLessonIds, setCompletedLessonIds] = useState<string[]>([]);
|
||||
const [showLoginPrompt, setShowLoginPrompt] = useState(false);
|
||||
@@ -41,28 +40,18 @@ export const CoursePlayerModal: React.FC<CoursePlayerModalProps> = ({ course, on
|
||||
|
||||
const videoRef = useRef<HTMLVideoElement>(null);
|
||||
|
||||
// Load course data & completed lessons from storage
|
||||
// Load course data and this learner's saved state from PostgreSQL.
|
||||
useEffect(() => {
|
||||
if (course) {
|
||||
incrementViews(course.id);
|
||||
getComments(course.id).then(setComments);
|
||||
setCurrentProgress(course.progress || 0);
|
||||
|
||||
// Load completed lessons from localStorage
|
||||
const storageKey = `course_${course.id}_completed_lessons`;
|
||||
const savedCompleted = localStorage.getItem(storageKey);
|
||||
if (savedCompleted) {
|
||||
try {
|
||||
setCompletedLessonIds(JSON.parse(savedCompleted));
|
||||
} catch (e) {
|
||||
setCompletedLessonIds([]);
|
||||
}
|
||||
} else if (course.progress && course.progress > 0 && course.lessons && course.lessons.length > 0) {
|
||||
// Initial demo completion based on progress
|
||||
const count = Math.ceil((course.progress / 100) * course.lessons.length);
|
||||
const initialCompleted = course.lessons.slice(0, count).map(l => l.id);
|
||||
setCompletedLessonIds(initialCompleted);
|
||||
localStorage.setItem(storageKey, JSON.stringify(initialCompleted));
|
||||
setCompletedLessonIds([]);
|
||||
if (user) {
|
||||
getComments(course.id).then(setComments).catch(() => setComments([]));
|
||||
getCompletedLessonIds(course.id).then((completed) => {
|
||||
setCompletedLessonIds(completed);
|
||||
}).catch(() => setCompletedLessonIds([]));
|
||||
} else {
|
||||
setComments([]);
|
||||
}
|
||||
|
||||
// Select first lesson by default
|
||||
@@ -70,28 +59,28 @@ export const CoursePlayerModal: React.FC<CoursePlayerModalProps> = ({ course, on
|
||||
setCurrentLesson(course.lessons[0]);
|
||||
}
|
||||
}
|
||||
}, [course]);
|
||||
}, [course, user]);
|
||||
|
||||
// Persist completed lessons and recalculate progress
|
||||
const updateCompletedLessons = (newCompleted: string[]) => {
|
||||
setCompletedLessonIds(newCompleted);
|
||||
if (course) {
|
||||
localStorage.setItem(`course_${course.id}_completed_lessons`, JSON.stringify(newCompleted));
|
||||
if (course.lessons && course.lessons.length > 0) {
|
||||
const percent = Math.round((newCompleted.length / course.lessons.length) * 100);
|
||||
setCurrentProgress(percent);
|
||||
saveProgress(course.id, percent);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const toggleLessonCompleted = (lessonId: string) => {
|
||||
if (!user) {
|
||||
setShowLoginPrompt(true);
|
||||
return;
|
||||
}
|
||||
if (completedLessonIds.includes(lessonId)) {
|
||||
const updated = completedLessonIds.filter(id => id !== lessonId);
|
||||
updateCompletedLessons(updated);
|
||||
saveLessonCompletion(lessonId, false).catch(() => updateCompletedLessons(completedLessonIds));
|
||||
} else {
|
||||
const updated = [...completedLessonIds, lessonId];
|
||||
updateCompletedLessons(updated);
|
||||
saveLessonCompletion(lessonId, true).catch(() => updateCompletedLessons(completedLessonIds));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -135,18 +124,19 @@ export const CoursePlayerModal: React.FC<CoursePlayerModalProps> = ({ course, on
|
||||
e.preventDefault();
|
||||
if (!newComment.trim() || !course) return;
|
||||
|
||||
const comment: Comment = {
|
||||
id: Date.now().toString(),
|
||||
const comment = {
|
||||
courseId: course.id,
|
||||
lessonId: currentLesson?.id,
|
||||
user: user?.name || 'Aluno Convidado',
|
||||
text: newComment,
|
||||
date: new Date().toLocaleDateString('pt-BR')
|
||||
};
|
||||
|
||||
await saveComment(comment);
|
||||
setComments([comment, ...comments]);
|
||||
setNewComment('');
|
||||
try {
|
||||
const savedComment = await saveComment(comment);
|
||||
setComments([savedComment, ...comments]);
|
||||
setNewComment('');
|
||||
} catch {
|
||||
// The server remains the source of truth; leave the text intact for a retry.
|
||||
}
|
||||
};
|
||||
|
||||
const changeLesson = (lesson: Lesson) => {
|
||||
|
||||
@@ -25,6 +25,12 @@ services:
|
||||
FRONTEND_ORIGIN: ${FRONTEND_ORIGIN:?Set the public https URL in Portainer}
|
||||
DATABASE_URL: postgresql://${POSTGRES_USER:-compor}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB:-compor_hub}
|
||||
JWT_SECRET: ${JWT_SECRET:?Set a long random JWT_SECRET in Portainer}
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "wget -q -O /dev/null http://localhost:3001/api/v1/health || exit 1"]
|
||||
interval: 15s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
start_period: 30s
|
||||
deploy:
|
||||
replicas: 1
|
||||
restart_policy:
|
||||
|
||||
@@ -75,9 +75,7 @@
|
||||
"react-dom/": "https://esm.sh/react-dom@18.2.0/",
|
||||
"react-dom/client": "https://esm.sh/react-dom@18.2.0/client",
|
||||
"react-router-dom": "https://esm.sh/react-router-dom@6.22.3?external=react,react-dom",
|
||||
"lucide-react": "https://esm.sh/lucide-react@0.344.0",
|
||||
"localforage": "https://esm.sh/localforage@1.10.0",
|
||||
"@google/genai": "https://esm.sh/@google/genai"
|
||||
"lucide-react": "https://esm.sh/lucide-react@0.344.0"
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -86,4 +84,4 @@
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/index.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
</html>
|
||||
|
||||
427
package-lock.json
generated
427
package-lock.json
generated
@@ -10,11 +10,9 @@
|
||||
"dependencies": {
|
||||
"@fastify/cors": "^11.0.0",
|
||||
"@fastify/jwt": "^9.1.0",
|
||||
"@google/genai": "latest",
|
||||
"dotenv": "^16.6.1",
|
||||
"fastify": "^5.3.2",
|
||||
"fastify-plugin": "^5.0.1",
|
||||
"localforage": "1.10.0",
|
||||
"lucide-react": "0.344.0",
|
||||
"pg": "^8.16.3",
|
||||
"react": "18.2.0",
|
||||
@@ -925,30 +923,6 @@
|
||||
"ipaddr.js": "^2.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@google/genai": {
|
||||
"version": "2.19.0",
|
||||
"resolved": "https://registry.npmjs.org/@google/genai/-/genai-2.19.0.tgz",
|
||||
"integrity": "sha512-IM3u1FjpOLyOPsPh0smGA6Ntj2olUWCPbTs/WQj7cA/2AKCGw/652W7IaySx0XtwuscFnMYBoraebQusSZ0zSQ==",
|
||||
"hasInstallScript": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"google-auth-library": "^10.3.0",
|
||||
"p-retry": "^4.6.2",
|
||||
"protobufjs": "^7.5.4",
|
||||
"ws": "^8.18.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@modelcontextprotocol/sdk": "^1.25.2"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@modelcontextprotocol/sdk": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@jridgewell/gen-mapping": {
|
||||
"version": "0.3.13",
|
||||
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
|
||||
@@ -1034,63 +1008,6 @@
|
||||
"integrity": "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@protobufjs/aspromise": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz",
|
||||
"integrity": "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==",
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/@protobufjs/base64": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@protobufjs/base64/-/base64-1.1.2.tgz",
|
||||
"integrity": "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==",
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/@protobufjs/codegen": {
|
||||
"version": "2.0.5",
|
||||
"resolved": "https://registry.npmjs.org/@protobufjs/codegen/-/codegen-2.0.5.tgz",
|
||||
"integrity": "sha512-zgXFLzW3Ap33e6d0Wlj4MGIm6Ce8O89n/apUaGNB/jx+hw+ruWEp7EwGUshdLKVRCxZW12fp9r40E1mQrf/34g==",
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/@protobufjs/eventemitter": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@protobufjs/eventemitter/-/eventemitter-1.1.1.tgz",
|
||||
"integrity": "sha512-vW1GmwMZNnL+gMRaovlh9yZX74kc+TTU3FObkkurpMaRtBfLP3ldjS9KQWlwZgraRE0+dheEEoAxdzcJQ8eXZg==",
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/@protobufjs/fetch": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@protobufjs/fetch/-/fetch-1.1.1.tgz",
|
||||
"integrity": "sha512-GpptLrs57adMSuHi3VNj0mAF8dwh36LMaYF6XyJ6JMWlVsc+t42tm1HSEDmOs3A8fC9yyeisgLhsTVQokOZ0zw==",
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"@protobufjs/aspromise": "^1.1.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@protobufjs/float": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@protobufjs/float/-/float-1.0.2.tgz",
|
||||
"integrity": "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==",
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/@protobufjs/path": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@protobufjs/path/-/path-1.1.2.tgz",
|
||||
"integrity": "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==",
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/@protobufjs/pool": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@protobufjs/pool/-/pool-1.1.0.tgz",
|
||||
"integrity": "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==",
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/@protobufjs/utf8": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.2.tgz",
|
||||
"integrity": "sha512-b1UQwcEZ4yCnMCD8DAL1VlbvBJE9/IX4FTIp7BG1xYpf29SLazLSrqUkj4w7Y5y7cCVP6E5tcqqcI0xemPkHug==",
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/@remix-run/router": {
|
||||
"version": "1.15.3",
|
||||
"resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.15.3.tgz",
|
||||
@@ -1552,6 +1469,7 @@
|
||||
"version": "22.20.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.1.tgz",
|
||||
"integrity": "sha512-EANqOCF9QFyra+4pfxUcX9STKJpCLjMbObVzljIJomAWSnuSIEAvyzEU53GaajbXJEgdh0iEcPL+DGvpUd4k1Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"undici-types": "~6.21.0"
|
||||
@@ -1569,12 +1487,6 @@
|
||||
"pg-types": "^2.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/retry": {
|
||||
"version": "0.12.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/retry/-/retry-0.12.0.tgz",
|
||||
"integrity": "sha512-wWKOClTTiizcZhXnPY4wikVAwmdYHp8q6DmC+EJUzAMsycb7HB32Kh9RN4+0gExjmPmZSAQjgURXIGATPegAvA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@vitejs/plugin-react": {
|
||||
"version": "5.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-5.2.0.tgz",
|
||||
@@ -1602,15 +1514,6 @@
|
||||
"integrity": "sha512-2BjRTZxTPvheOvGbBslFSYOUkr+SjPtOnrLP33f+VIWLzezQpZcqVg7ja3L4dBXmzzgwT+a029jRx5PCi3JuiA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/agent-base": {
|
||||
"version": "7.1.4",
|
||||
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz",
|
||||
"integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 14"
|
||||
}
|
||||
},
|
||||
"node_modules/ajv": {
|
||||
"version": "8.20.0",
|
||||
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz",
|
||||
@@ -1701,26 +1604,6 @@
|
||||
"fastq": "^1.17.1"
|
||||
}
|
||||
},
|
||||
"node_modules/base64-js": {
|
||||
"version": "1.5.1",
|
||||
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
|
||||
"integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/feross"
|
||||
},
|
||||
{
|
||||
"type": "patreon",
|
||||
"url": "https://www.patreon.com/feross"
|
||||
},
|
||||
{
|
||||
"type": "consulting",
|
||||
"url": "https://feross.org/support"
|
||||
}
|
||||
],
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/baseline-browser-mapping": {
|
||||
"version": "2.11.20",
|
||||
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.20.tgz",
|
||||
@@ -1734,15 +1617,6 @@
|
||||
"node": ">=6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/bignumber.js": {
|
||||
"version": "9.3.1",
|
||||
"resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-9.3.1.tgz",
|
||||
"integrity": "sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/bn.js": {
|
||||
"version": "4.12.5",
|
||||
"resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.5.tgz",
|
||||
@@ -1783,12 +1657,6 @@
|
||||
"node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
|
||||
}
|
||||
},
|
||||
"node_modules/buffer-equal-constant-time": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz",
|
||||
"integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==",
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/caniuse-lite": {
|
||||
"version": "1.0.30001810",
|
||||
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001810.tgz",
|
||||
@@ -1830,19 +1698,11 @@
|
||||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/data-uri-to-buffer": {
|
||||
"version": "4.0.1",
|
||||
"resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz",
|
||||
"integrity": "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 12"
|
||||
}
|
||||
},
|
||||
"node_modules/debug": {
|
||||
"version": "4.4.3",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
||||
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ms": "^2.1.3"
|
||||
@@ -1945,12 +1805,6 @@
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/extend": {
|
||||
"version": "3.0.2",
|
||||
"resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz",
|
||||
"integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/fast-decode-uri-component": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/fast-decode-uri-component/-/fast-decode-uri-component-1.0.1.tgz",
|
||||
@@ -2147,29 +2001,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/fetch-blob": {
|
||||
"version": "3.2.0",
|
||||
"resolved": "https://registry.npmjs.org/fetch-blob/-/fetch-blob-3.2.0.tgz",
|
||||
"integrity": "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/jimmywarting"
|
||||
},
|
||||
{
|
||||
"type": "paypal",
|
||||
"url": "https://paypal.me/jimmywarting"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"node-domexception": "^1.0.0",
|
||||
"web-streams-polyfill": "^3.0.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^12.20 || >= 14.13"
|
||||
}
|
||||
},
|
||||
"node_modules/find-my-way": {
|
||||
"version": "9.9.0",
|
||||
"resolved": "https://registry.npmjs.org/find-my-way/-/find-my-way-9.9.0.tgz",
|
||||
@@ -2184,18 +2015,6 @@
|
||||
"node": ">=20"
|
||||
}
|
||||
},
|
||||
"node_modules/formdata-polyfill": {
|
||||
"version": "4.0.10",
|
||||
"resolved": "https://registry.npmjs.org/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz",
|
||||
"integrity": "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"fetch-blob": "^3.1.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12.20.0"
|
||||
}
|
||||
},
|
||||
"node_modules/fsevents": {
|
||||
"version": "2.3.3",
|
||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
|
||||
@@ -2211,34 +2030,6 @@
|
||||
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/gaxios": {
|
||||
"version": "7.3.1",
|
||||
"resolved": "https://registry.npmjs.org/gaxios/-/gaxios-7.3.1.tgz",
|
||||
"integrity": "sha512-kB3rzJV7d9juLZh8/56QTXCwQfxyhdOMdyYk1HdQKFtF8TJTDTZQJtixWIwXdE9Jji91mC41DUNpjleo4L4eAQ==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"extend": "^3.0.2",
|
||||
"https-proxy-agent": "^7.0.1",
|
||||
"node-fetch": "^3.3.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/gcp-metadata": {
|
||||
"version": "8.1.2",
|
||||
"resolved": "https://registry.npmjs.org/gcp-metadata/-/gcp-metadata-8.1.2.tgz",
|
||||
"integrity": "sha512-zV/5HKTfCeKWnxG0Dmrw51hEWFGfcF2xiXqcA3+J90WDuP0SvoiSO5ORvcBsifmx/FoIjgQN3oNOGaQ5PhLFkg==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"gaxios": "^7.0.0",
|
||||
"google-logging-utils": "^1.0.0",
|
||||
"json-bigint": "^1.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/gensync": {
|
||||
"version": "1.0.0-beta.2",
|
||||
"resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz",
|
||||
@@ -2249,51 +2040,6 @@
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/google-auth-library": {
|
||||
"version": "10.9.1",
|
||||
"resolved": "https://registry.npmjs.org/google-auth-library/-/google-auth-library-10.9.1.tgz",
|
||||
"integrity": "sha512-i1ydyHrqcIxXkWh/uBmVkzCvIuq5yiK2ATndIe5XxKholrG/MTYP9xGYka4sQhrbIAgGjL2B6NOE7rFaiF3fXw==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"base64-js": "^1.3.0",
|
||||
"ecdsa-sig-formatter": "^1.0.11",
|
||||
"gaxios": "^7.1.4",
|
||||
"gcp-metadata": "8.1.2",
|
||||
"google-logging-utils": "1.1.3",
|
||||
"jws": "^4.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/google-logging-utils": {
|
||||
"version": "1.1.3",
|
||||
"resolved": "https://registry.npmjs.org/google-logging-utils/-/google-logging-utils-1.1.3.tgz",
|
||||
"integrity": "sha512-eAmLkjDjAFCVXg7A1unxHsLf961m6y17QFqXqAXGj/gVkKFrEICfStRfwUlGNfeCEjNRa32JEWOUTlYXPyyKvA==",
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=14"
|
||||
}
|
||||
},
|
||||
"node_modules/https-proxy-agent": {
|
||||
"version": "7.0.6",
|
||||
"resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz",
|
||||
"integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"agent-base": "^7.1.2",
|
||||
"debug": "4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 14"
|
||||
}
|
||||
},
|
||||
"node_modules/immediate": {
|
||||
"version": "3.0.6",
|
||||
"resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz",
|
||||
"integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/inherits": {
|
||||
"version": "2.0.4",
|
||||
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
|
||||
@@ -2328,15 +2074,6 @@
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/json-bigint": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/json-bigint/-/json-bigint-1.0.0.tgz",
|
||||
"integrity": "sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"bignumber.js": "^9.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/json-schema-ref-resolver": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/json-schema-ref-resolver/-/json-schema-ref-resolver-3.0.0.tgz",
|
||||
@@ -2375,36 +2112,6 @@
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/jwa": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz",
|
||||
"integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"buffer-equal-constant-time": "^1.0.1",
|
||||
"ecdsa-sig-formatter": "1.0.11",
|
||||
"safe-buffer": "^5.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/jws": {
|
||||
"version": "4.0.1",
|
||||
"resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz",
|
||||
"integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"jwa": "^2.0.1",
|
||||
"safe-buffer": "^5.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/lie": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/lie/-/lie-3.1.1.tgz",
|
||||
"integrity": "sha512-RiNhHysUjhrDQntfYSfY4MU24coXXdEOgw9WGcKHNeEwffDYbF//u87M1EWaMGzuFoSbqW0C9C6lEEhDOAswfw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"immediate": "~3.0.5"
|
||||
}
|
||||
},
|
||||
"node_modules/light-my-request": {
|
||||
"version": "6.6.0",
|
||||
"resolved": "https://registry.npmjs.org/light-my-request/-/light-my-request-6.6.0.tgz",
|
||||
@@ -2442,21 +2149,6 @@
|
||||
],
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/localforage": {
|
||||
"version": "1.10.0",
|
||||
"resolved": "https://registry.npmjs.org/localforage/-/localforage-1.10.0.tgz",
|
||||
"integrity": "sha512-14/H1aX7hzBBmmh7sGPd+AOMkkIrHM3Z1PAyGgZigA1H1p5O5ANnMyWzvpAETtG68/dC4pC0ncy3+PPGzXZHPg==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"lie": "3.1.1"
|
||||
}
|
||||
},
|
||||
"node_modules/long": {
|
||||
"version": "5.3.2",
|
||||
"resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz",
|
||||
"integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==",
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/loose-envify": {
|
||||
"version": "1.4.0",
|
||||
"resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
|
||||
@@ -2507,6 +2199,7 @@
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/nanoid": {
|
||||
@@ -2528,44 +2221,6 @@
|
||||
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/node-domexception": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz",
|
||||
"integrity": "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==",
|
||||
"deprecated": "Use your platform's native DOMException instead",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/jimmywarting"
|
||||
},
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://paypal.me/jimmywarting"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10.5.0"
|
||||
}
|
||||
},
|
||||
"node_modules/node-fetch": {
|
||||
"version": "3.3.2",
|
||||
"resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-3.3.2.tgz",
|
||||
"integrity": "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"data-uri-to-buffer": "^4.0.0",
|
||||
"fetch-blob": "^3.1.4",
|
||||
"formdata-polyfill": "^4.0.10"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^12.20.0 || ^14.13.1 || >=16.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/node-fetch"
|
||||
}
|
||||
},
|
||||
"node_modules/node-releases": {
|
||||
"version": "2.0.54",
|
||||
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.54.tgz",
|
||||
@@ -2591,19 +2246,6 @@
|
||||
"node": ">=14.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/p-retry": {
|
||||
"version": "4.6.2",
|
||||
"resolved": "https://registry.npmjs.org/p-retry/-/p-retry-4.6.2.tgz",
|
||||
"integrity": "sha512-312Id396EbJdvRONlngUx0NydfrIQ5lsYu0znKVUzVvArzEIt08V1qhtyESbGVd1FGX7UKtiFp5uwKZdM8wIuQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/retry": "0.12.0",
|
||||
"retry": "^0.13.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/pg": {
|
||||
"version": "8.23.0",
|
||||
"resolved": "https://registry.npmjs.org/pg/-/pg-8.23.0.tgz",
|
||||
@@ -2834,29 +2476,6 @@
|
||||
],
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/protobufjs": {
|
||||
"version": "7.6.6",
|
||||
"resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.6.6.tgz",
|
||||
"integrity": "sha512-dYDWdjSl5RNb7SgPxGQcRU+GtvP7s2fpkrY0r432PcOIaZ0/rBcxEZnQN67iJhFuQiVw754JDoPruPCNdGsbjg==",
|
||||
"hasInstallScript": true,
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"@protobufjs/aspromise": "^1.1.2",
|
||||
"@protobufjs/base64": "^1.1.2",
|
||||
"@protobufjs/codegen": "^2.0.5",
|
||||
"@protobufjs/eventemitter": "^1.1.1",
|
||||
"@protobufjs/fetch": "^1.1.1",
|
||||
"@protobufjs/float": "^1.0.2",
|
||||
"@protobufjs/path": "^1.1.2",
|
||||
"@protobufjs/pool": "^1.1.0",
|
||||
"@protobufjs/utf8": "^1.1.1",
|
||||
"@types/node": ">=13.7.0",
|
||||
"long": "^5.3.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/quick-format-unescaped": {
|
||||
"version": "4.0.4",
|
||||
"resolved": "https://registry.npmjs.org/quick-format-unescaped/-/quick-format-unescaped-4.0.4.tgz",
|
||||
@@ -2957,15 +2576,6 @@
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/retry": {
|
||||
"version": "0.13.1",
|
||||
"resolved": "https://registry.npmjs.org/retry/-/retry-0.13.1.tgz",
|
||||
"integrity": "sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 4"
|
||||
}
|
||||
},
|
||||
"node_modules/reusify": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz",
|
||||
@@ -3248,6 +2858,7 @@
|
||||
"version": "6.21.0",
|
||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
|
||||
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/update-browserslist-db": {
|
||||
@@ -3840,36 +3451,6 @@
|
||||
"@esbuild/win32-x64": "0.25.12"
|
||||
}
|
||||
},
|
||||
"node_modules/web-streams-polyfill": {
|
||||
"version": "3.3.3",
|
||||
"resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz",
|
||||
"integrity": "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 8"
|
||||
}
|
||||
},
|
||||
"node_modules/ws": {
|
||||
"version": "8.21.3",
|
||||
"resolved": "https://registry.npmjs.org/ws/-/ws-8.21.3.tgz",
|
||||
"integrity": "sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"bufferutil": "^4.0.1",
|
||||
"utf-8-validate": ">=5.0.2"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"bufferutil": {
|
||||
"optional": true
|
||||
},
|
||||
"utf-8-validate": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/xtend": {
|
||||
"version": "4.0.2",
|
||||
"resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
|
||||
|
||||
17
package.json
17
package.json
@@ -17,25 +17,24 @@
|
||||
"typecheck": "npm run typecheck:client && npm run typecheck:api"
|
||||
},
|
||||
"dependencies": {
|
||||
"react": "18.2.0",
|
||||
"react-dom": "18.2.0",
|
||||
"react-router-dom": "6.22.3",
|
||||
"lucide-react": "0.344.0",
|
||||
"localforage": "1.10.0",
|
||||
"@fastify/cors": "^11.0.0",
|
||||
"@fastify/jwt": "^9.1.0",
|
||||
"dotenv": "^16.6.1",
|
||||
"fastify": "^5.3.2",
|
||||
"fastify-plugin": "^5.0.1",
|
||||
"lucide-react": "0.344.0",
|
||||
"pg": "^8.16.3",
|
||||
"react": "18.2.0",
|
||||
"react-dom": "18.2.0",
|
||||
"react-router-dom": "6.22.3",
|
||||
"zod": "^3.24.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.14.0",
|
||||
"@vitejs/plugin-react": "^5.0.0",
|
||||
"typescript": "~5.8.2",
|
||||
"vite": "^6.2.0",
|
||||
"@types/pg": "^8.15.4",
|
||||
"tsx": "^4.19.4"
|
||||
"@vitejs/plugin-react": "^5.0.0",
|
||||
"tsx": "^4.19.4",
|
||||
"typescript": "~5.8.2",
|
||||
"vite": "^6.2.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import { pool } from './db/pool.js';
|
||||
import { authRoutes } from './routes/auth.js';
|
||||
import { courseRoutes } from './routes/courses.js';
|
||||
import { manageCourseRoutes } from './routes/manage-courses.js';
|
||||
import { learningRoutes } from './routes/learning.js';
|
||||
|
||||
export function buildApp() {
|
||||
const app = Fastify({ logger: true });
|
||||
@@ -25,19 +26,21 @@ export function buildApp() {
|
||||
return reply.code(500).send({ error: 'Internal server error' });
|
||||
});
|
||||
|
||||
app.get('/api/v1/health', async () => ({ status: 'ok' }));
|
||||
app.get('/api/v1/ready', async (_request, reply) => {
|
||||
const checkDatabase = async (_request: unknown, reply: { code: (statusCode: number) => { send: (payload: object) => unknown } }) => {
|
||||
try {
|
||||
await pool.query('select 1');
|
||||
return { status: 'ready' };
|
||||
return { status: 'ok', database: 'connected' };
|
||||
} catch {
|
||||
return reply.code(503).send({ status: 'unavailable' });
|
||||
return reply.code(503).send({ status: 'unavailable', database: 'unavailable' });
|
||||
}
|
||||
});
|
||||
};
|
||||
app.get('/api/v1/health', checkDatabase);
|
||||
app.get('/api/v1/ready', checkDatabase);
|
||||
|
||||
app.register(authPlugin);
|
||||
app.register(authRoutes, { prefix: '/api/v1/auth' });
|
||||
app.register(courseRoutes, { prefix: '/api/v1/courses' });
|
||||
app.register(manageCourseRoutes, { prefix: '/api/v1/manage/courses' });
|
||||
app.register(learningRoutes, { prefix: '/api/v1' });
|
||||
return app;
|
||||
}
|
||||
|
||||
126
server/src/routes/learning.ts
Normal file
126
server/src/routes/learning.ts
Normal file
@@ -0,0 +1,126 @@
|
||||
import type { FastifyPluginAsync } from 'fastify';
|
||||
import { z } from 'zod';
|
||||
import { pool } from '../db/pool.js';
|
||||
|
||||
const courseParamsSchema = z.object({
|
||||
courseId: z.string().uuid(),
|
||||
});
|
||||
|
||||
const lessonParamsSchema = z.object({
|
||||
lessonId: z.string().uuid(),
|
||||
});
|
||||
|
||||
const completionSchema = z.object({
|
||||
completed: z.boolean(),
|
||||
watchedSeconds: z.coerce.number().int().min(0).optional(),
|
||||
});
|
||||
|
||||
const commentSchema = z.object({
|
||||
lessonId: z.string().uuid().nullable().optional(),
|
||||
text: z.string().trim().min(1).max(4000),
|
||||
});
|
||||
|
||||
const ensurePublishedCourse = async (courseId: string) => {
|
||||
const result = await pool.query(
|
||||
"select id from courses where id = $1 and status = 'published'",
|
||||
[courseId],
|
||||
);
|
||||
return result.rowCount === 1;
|
||||
};
|
||||
|
||||
export const learningRoutes: FastifyPluginAsync = async (app) => {
|
||||
app.get('/courses/:courseId/progress', { preHandler: app.authenticate }, async (request, reply) => {
|
||||
const { courseId } = courseParamsSchema.parse(request.params);
|
||||
if (!(await ensurePublishedCourse(courseId))) {
|
||||
return reply.code(404).send({ error: 'Course not found' });
|
||||
}
|
||||
|
||||
const result = await pool.query(
|
||||
`select lp.lesson_id as "lessonId", lp.watched_seconds as "watchedSeconds",
|
||||
lp.completed_at as "completedAt", lp.updated_at as "updatedAt"
|
||||
from lesson_progress lp
|
||||
join lessons l on l.id = lp.lesson_id
|
||||
where lp.user_id = $1 and l.course_id = $2
|
||||
order by l.position`,
|
||||
[request.user.id, courseId],
|
||||
);
|
||||
return { data: result.rows };
|
||||
});
|
||||
|
||||
app.put('/lessons/:lessonId/progress', { preHandler: app.authenticate }, async (request, reply) => {
|
||||
const { lessonId } = lessonParamsSchema.parse(request.params);
|
||||
const input = completionSchema.parse(request.body);
|
||||
const lesson = await pool.query(
|
||||
`select l.id
|
||||
from lessons l
|
||||
join courses c on c.id = l.course_id
|
||||
where l.id = $1 and c.status = 'published'`,
|
||||
[lessonId],
|
||||
);
|
||||
|
||||
if (lesson.rowCount !== 1) {
|
||||
return reply.code(404).send({ error: 'Lesson not found' });
|
||||
}
|
||||
|
||||
const result = await pool.query(
|
||||
`insert into lesson_progress (lesson_id, user_id, watched_seconds, completed_at)
|
||||
values ($1, $2, $3, case when $4 then now() else null end)
|
||||
on conflict (lesson_id, user_id) do update set
|
||||
watched_seconds = greatest(lesson_progress.watched_seconds, excluded.watched_seconds),
|
||||
completed_at = case when $4 then coalesce(lesson_progress.completed_at, now()) else null end
|
||||
returning lesson_id as "lessonId", watched_seconds as "watchedSeconds",
|
||||
completed_at as "completedAt", updated_at as "updatedAt"`,
|
||||
[lessonId, request.user.id, input.watchedSeconds || 0, input.completed],
|
||||
);
|
||||
return { data: result.rows[0] };
|
||||
});
|
||||
|
||||
app.get('/courses/:courseId/comments', { preHandler: app.authenticate }, async (request, reply) => {
|
||||
const { courseId } = courseParamsSchema.parse(request.params);
|
||||
if (!(await ensurePublishedCourse(courseId))) {
|
||||
return reply.code(404).send({ error: 'Course not found' });
|
||||
}
|
||||
|
||||
const result = await pool.query(
|
||||
`select c.id, c.course_id as "courseId", c.lesson_id as "lessonId", c.body as text,
|
||||
c.created_at as "createdAt", u.display_name as "user",
|
||||
cr.body as reply, cr.created_at as "replyCreatedAt"
|
||||
from comments c
|
||||
join users u on u.id = c.author_id
|
||||
left join comment_replies cr on cr.comment_id = c.id
|
||||
where c.course_id = $1
|
||||
order by c.created_at desc`,
|
||||
[courseId],
|
||||
);
|
||||
return { data: result.rows };
|
||||
});
|
||||
|
||||
app.post('/courses/:courseId/comments', { preHandler: app.authenticate }, async (request, reply) => {
|
||||
const { courseId } = courseParamsSchema.parse(request.params);
|
||||
const input = commentSchema.parse(request.body);
|
||||
if (!(await ensurePublishedCourse(courseId))) {
|
||||
return reply.code(404).send({ error: 'Course not found' });
|
||||
}
|
||||
|
||||
if (input.lessonId) {
|
||||
const lesson = await pool.query('select id from lessons where id = $1 and course_id = $2', [input.lessonId, courseId]);
|
||||
if (lesson.rowCount !== 1) {
|
||||
return reply.code(400).send({ error: 'Lesson does not belong to this course' });
|
||||
}
|
||||
}
|
||||
|
||||
const result = await pool.query(
|
||||
`with inserted as (
|
||||
insert into comments (course_id, lesson_id, author_id, body)
|
||||
values ($1, $2, $3, $4)
|
||||
returning id, course_id, lesson_id, body, created_at
|
||||
)
|
||||
select inserted.id, inserted.course_id as "courseId", inserted.lesson_id as "lessonId",
|
||||
inserted.body as text, inserted.created_at as "createdAt", u.display_name as "user"
|
||||
from inserted
|
||||
join users u on u.id = $3`,
|
||||
[courseId, input.lessonId || null, request.user.id, input.text],
|
||||
);
|
||||
return reply.code(201).send({ data: result.rows[0] });
|
||||
});
|
||||
};
|
||||
@@ -1,9 +1,6 @@
|
||||
import localforage from 'localforage';
|
||||
import { Attachment, AttachmentType, Comment, Course, Lesson } from '../types';
|
||||
import { apiRequest } from './api';
|
||||
|
||||
const COMMENTS_KEY = 'compor_hub_comments_v1';
|
||||
|
||||
export const initDB = async (): Promise<void> => undefined;
|
||||
|
||||
type ApiAsset = {
|
||||
@@ -148,19 +145,53 @@ export const deleteCourse = async (id: string): Promise<void> => {
|
||||
await apiRequest(`/manage/courses/${id}`, { method: 'DELETE' });
|
||||
};
|
||||
|
||||
// Comments and learner progress move to PostgreSQL in the next milestone.
|
||||
type ApiComment = {
|
||||
id: string;
|
||||
courseId: string;
|
||||
lessonId: string | null;
|
||||
user: string;
|
||||
text: string;
|
||||
createdAt: string;
|
||||
reply?: string | null;
|
||||
replyCreatedAt?: string | null;
|
||||
};
|
||||
|
||||
const toComment = (comment: ApiComment): Comment => ({
|
||||
id: comment.id,
|
||||
courseId: comment.courseId,
|
||||
lessonId: comment.lessonId || undefined,
|
||||
user: comment.user,
|
||||
text: comment.text,
|
||||
date: new Intl.DateTimeFormat('pt-BR').format(new Date(comment.createdAt)),
|
||||
reply: comment.reply || undefined,
|
||||
replyDate: comment.replyCreatedAt
|
||||
? new Intl.DateTimeFormat('pt-BR').format(new Date(comment.replyCreatedAt))
|
||||
: undefined,
|
||||
});
|
||||
|
||||
export const getComments = async (courseId: string): Promise<Comment[]> => {
|
||||
const allComments = (await localforage.getItem<Comment[]>(COMMENTS_KEY)) || [];
|
||||
return allComments.filter((comment) => comment.courseId === courseId);
|
||||
const response = await apiRequest<{ data: ApiComment[] }>(`/courses/${courseId}/comments`);
|
||||
return response.data.map(toComment);
|
||||
};
|
||||
|
||||
export const saveComment = async (comment: Comment): Promise<void> => {
|
||||
const allComments = (await localforage.getItem<Comment[]>(COMMENTS_KEY)) || [];
|
||||
const index = allComments.findIndex((item) => item.id === comment.id);
|
||||
if (index >= 0) allComments[index] = comment;
|
||||
else allComments.unshift(comment);
|
||||
await localforage.setItem(COMMENTS_KEY, allComments);
|
||||
export const saveComment = async (comment: Pick<Comment, 'courseId' | 'lessonId' | 'text'>): Promise<Comment> => {
|
||||
const response = await apiRequest<{ data: ApiComment }>(`/courses/${comment.courseId}/comments`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ lessonId: comment.lessonId || null, text: comment.text }),
|
||||
});
|
||||
return toComment(response.data);
|
||||
};
|
||||
|
||||
export const getCompletedLessonIds = async (courseId: string): Promise<string[]> => {
|
||||
const response = await apiRequest<{ data: Array<{ lessonId: string; completedAt: string | null }> }>(`/courses/${courseId}/progress`);
|
||||
return response.data.filter((progress) => progress.completedAt).map((progress) => progress.lessonId);
|
||||
};
|
||||
|
||||
export const saveLessonCompletion = async (lessonId: string, completed: boolean): Promise<void> => {
|
||||
await apiRequest(`/lessons/${lessonId}/progress`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ completed }),
|
||||
});
|
||||
};
|
||||
|
||||
export const saveProgress = async (_courseId: string, _progress: number): Promise<void> => undefined;
|
||||
export const incrementViews = async (_courseId: string): Promise<void> => undefined;
|
||||
|
||||
Reference in New Issue
Block a user