diff --git a/BACKEND.md b/BACKEND.md index a282e6b..7574b73 100644 --- a/BACKEND.md +++ b/BACKEND.md @@ -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 diff --git a/PORTAINER.md b/PORTAINER.md index a672391..de8bf55 100644 --- a/PORTAINER.md +++ b/PORTAINER.md @@ -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. diff --git a/components/CoursePlayerModal.tsx b/components/CoursePlayerModal.tsx index b81fb30..6b53e48 100644 --- a/components/CoursePlayerModal.tsx +++ b/components/CoursePlayerModal.tsx @@ -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 = ({ course, on const { user } = useAuth(); const [comments, setComments] = useState([]); const [newComment, setNewComment] = useState(''); - const [currentProgress, setCurrentProgress] = useState(0); const [currentLesson, setCurrentLesson] = useState(null); const [completedLessonIds, setCompletedLessonIds] = useState([]); const [showLoginPrompt, setShowLoginPrompt] = useState(false); @@ -41,28 +40,18 @@ export const CoursePlayerModal: React.FC = ({ course, on const videoRef = useRef(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 = ({ 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 = ({ 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) => { diff --git a/docker-compose.yml b/docker-compose.yml index 98b2da7..77ef5c1 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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: diff --git a/index.html b/index.html index 0360fab..61ab530 100644 --- a/index.html +++ b/index.html @@ -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" } } @@ -86,4 +84,4 @@
- \ No newline at end of file + diff --git a/package-lock.json b/package-lock.json index 7794c37..77c36af 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index 4e42022..2b9bf37 100644 --- a/package.json +++ b/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" } } diff --git a/server/src/app.ts b/server/src/app.ts index 32f1552..b2eb493 100644 --- a/server/src/app.ts +++ b/server/src/app.ts @@ -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; } diff --git a/server/src/routes/learning.ts b/server/src/routes/learning.ts new file mode 100644 index 0000000..fad5b9f --- /dev/null +++ b/server/src/routes/learning.ts @@ -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] }); + }); +}; diff --git a/services/db.ts b/services/db.ts index e0451a1..df4ef3d 100644 --- a/services/db.ts +++ b/services/db.ts @@ -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 => undefined; type ApiAsset = { @@ -148,19 +145,53 @@ export const deleteCourse = async (id: string): Promise => { 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 => { - const allComments = (await localforage.getItem(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 => { - const allComments = (await localforage.getItem(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): Promise => { + 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 => { + 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 => { + await apiRequest(`/lessons/${lessonId}/progress`, { + method: 'PUT', + body: JSON.stringify({ completed }), + }); }; -export const saveProgress = async (_courseId: string, _progress: number): Promise => undefined; export const incrementViews = async (_courseId: string): Promise => undefined;