feat: persist learner progress and comments
All checks were successful
CI / Validate frontend and API (push) Successful in 47s
CI / Build and publish Docker images (push) Successful in 39s

This commit is contained in:
Cauê Faleiros
2026-08-31 16:33:08 -03:00
parent 7842a757cd
commit 2dfca33758
10 changed files with 225 additions and 491 deletions

View File

@@ -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

View File

@@ -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.

View File

@@ -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) => {

View File

@@ -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:

View File

@@ -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
View File

@@ -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",

View File

@@ -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"
}
}

View File

@@ -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;
}

View 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] });
});
};

View File

@@ -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;