diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..2803633 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,17 @@ +node_modules +.next +.git +.gitignore +e2e +.playwright +playwright-report +test-results +*.md +.env +.env.local +.env*.local +docker-compose.yml +Dockerfile +.dockerignore +eslint.config.mjs +tsconfig.json diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..17f63a4 --- /dev/null +++ b/.env.example @@ -0,0 +1,10 @@ +# EduEasy configuration + +# Database +DATABASE_URL="postgresql://edueasy:edueasy_pass@localhost:5432/edueasy?schema=public" + +# Auth (change in production) +NEXTAUTH_SECRET="change-me-in-production" + +# Environment +NODE_ENV="development" diff --git a/README.md b/README.md new file mode 100644 index 0000000..ac05023 --- /dev/null +++ b/README.md @@ -0,0 +1,31 @@ +# EduEasy + +Plataforma de aprendizaje para niños con métodos pedagógicos europeos (Montessori, Borel-Maisonny, Decroly, Freinet). + +## Perfiles + +| Perfil | Edad | Materias | +|--------|------|----------| +| Isabella | 4 años | Lectura fonética + conteo 1-20 | +| Francesca | 6 años | Operaciones multidígito + comprensión lectora | +| Sebastián | 8 años | Fracciones/decimales + historia/geografía argentina | + +## Instalación rápida + +```bash +docker compose up -d +``` + +App disponible en `http://localhost:3000`. + +## Instalación manual + +Ver [INSTALL.md](./INSTALL.md). + +## Documentación + +- [INSTALL.md](./INSTALL.md) — Guía de instalación +- [fase2.md](./fase2.md) — Plan de expansión multi-perfil +- [fase3.md](./fase3.md) — Bug fixes y hardening +- [fase4.md](./fase4.md) — Overhaul, Docker, métodos europeos +- [fase5.md](./fase5.md) — Seguridad e infraestructura diff --git a/app/api/children/route.ts b/app/api/children/route.ts index e303597..dd41b6a 100644 --- a/app/api/children/route.ts +++ b/app/api/children/route.ts @@ -3,55 +3,65 @@ import { prisma } from "@/lib/db" import { auth } from "@/lib/auth" export async function GET(request: NextRequest) { - const session = await auth.api.getSession({ headers: request.headers }) - if (!session?.user?.id) { - return NextResponse.json({ error: "No autorizado" }, { status: 401 }) - } + try { + const session = await auth.api.getSession({ headers: request.headers }) + if (!session?.user?.id) { + return NextResponse.json({ error: "No autorizado" }, { status: 401 }) + } - const parent = await prisma.parent.findUnique({ - where: { id: session.user.id }, - include: { - family: { - include: { children: true }, + const parent = await prisma.parent.findUnique({ + where: { id: session.user.id }, + include: { + family: { + include: { children: true }, + }, }, - }, - }) + }) - if (!parent || !parent.family) { - return NextResponse.json({ error: "Parent or family not found" }, { status: 404 }) + if (!parent || !parent.family) { + return NextResponse.json({ error: "Parent or family not found" }, { status: 404 }) + } + + return NextResponse.json(parent.family.children) + } catch (error) { + console.error("[children GET] error:", error) + return NextResponse.json({ error: "Internal error" }, { status: 500 }) } - - return NextResponse.json(parent.family.children) } export async function POST(request: Request) { - const session = await auth.api.getSession({ headers: request.headers }) - if (!session?.user?.id) { - return NextResponse.json({ error: "No autorizado" }, { status: 401 }) + try { + const session = await auth.api.getSession({ headers: request.headers }) + if (!session?.user?.id) { + return NextResponse.json({ error: "No autorizado" }, { status: 401 }) + } + + const { name, birthdate, profileNotes } = await request.json() + if (!name) { + return NextResponse.json({ error: "name required" }, { status: 400 }) + } + + const parent = await prisma.parent.findUnique({ + where: { id: session.user.id }, + select: { familyId: true }, + }) + + if (!parent || !parent.familyId) { + return NextResponse.json({ error: "Parent has no family" }, { status: 400 }) + } + + const child = await prisma.child.create({ + data: { + name, + birthdate: birthdate ? new Date(birthdate) : undefined, + profileNotes, + familyId: parent.familyId, + }, + }) + + return NextResponse.json(child, { status: 201 }) + } catch (error) { + console.error("[children POST] error:", error) + return NextResponse.json({ error: "Internal error" }, { status: 500 }) } - - const { name, birthdate, profileNotes } = await request.json() - if (!name) { - return NextResponse.json({ error: "name required" }, { status: 400 }) - } - - const parent = await prisma.parent.findUnique({ - where: { id: session.user.id }, - select: { familyId: true }, - }) - - if (!parent || !parent.familyId) { - return NextResponse.json({ error: "Parent has no family" }, { status: 400 }) - } - - const child = await prisma.child.create({ - data: { - name, - birthdate: birthdate ? new Date(birthdate) : undefined, - profileNotes, - familyId: parent.familyId, - }, - }) - - return NextResponse.json(child, { status: 201 }) } diff --git a/app/api/curriculum/next/route.ts b/app/api/curriculum/next/route.ts index e8720cb..c6b4296 100644 --- a/app/api/curriculum/next/route.ts +++ b/app/api/curriculum/next/route.ts @@ -3,18 +3,27 @@ import { prisma } from "@/lib/db" import { getNextExercise } from "@/lib/curriculum/engine" export async function GET(request: NextRequest) { - const childId = request.nextUrl.searchParams.get("childId") - const topic = request.nextUrl.searchParams.get("topic") || "lectura" - if (!childId) { - return NextResponse.json({ error: "childId required" }, { status: 400 }) + try { + const childId = request.nextUrl.searchParams.get("childId") + const topic = request.nextUrl.searchParams.get("topic") || "lectura" + if (!childId) { + return NextResponse.json({ error: "childId required" }, { status: 400 }) + } + + const child = await prisma.child.findUnique({ + where: { id: childId }, + select: { profile: true }, + }) + + if (!child) { + return NextResponse.json({ error: "child not found" }, { status: 404 }) + } + + const profile = (request.nextUrl.searchParams.get("profile") || child.profile || "ISABELLA").toUpperCase() + const exercise = await getNextExercise(childId, topic, profile) + return NextResponse.json(exercise || { id: null, done: true }) + } catch (error) { + console.error("[curriculum/next] error:", error) + return NextResponse.json({ error: "Internal error" }, { status: 500 }) } - - const child = await prisma.child.findUnique({ - where: { id: childId }, - select: { profile: true }, - }) - - const profile = (request.nextUrl.searchParams.get("profile") || child?.profile || "ISABELLA").toUpperCase() - const exercise = await getNextExercise(childId, topic, profile) - return NextResponse.json(exercise || { id: null, done: true }) } diff --git a/app/api/dashboard/summary/route.ts b/app/api/dashboard/summary/route.ts index 43161b1..797003f 100644 --- a/app/api/dashboard/summary/route.ts +++ b/app/api/dashboard/summary/route.ts @@ -2,10 +2,11 @@ import { NextRequest, NextResponse } from "next/server" import { prisma } from "@/lib/db" export async function GET(request: NextRequest) { - const childId = request.nextUrl.searchParams.get("childId") - if (!childId) { - return NextResponse.json({ error: "childId required" }, { status: 400 }) - } + try { + const childId = request.nextUrl.searchParams.get("childId") + if (!childId) { + return NextResponse.json({ error: "childId required" }, { status: 400 }) + } const today = new Date() today.setHours(0, 0, 0, 0) @@ -118,6 +119,10 @@ export async function GET(request: NextRequest) { } : null, }) + } catch (error) { + console.error("[dashboard/summary] error:", error) + return NextResponse.json({ error: "Internal error" }, { status: 500 }) + } } async function calculateStreak(childId: string): Promise { diff --git a/app/api/fsrs/next/route.ts b/app/api/fsrs/next/route.ts index d502596..17b9d89 100644 --- a/app/api/fsrs/next/route.ts +++ b/app/api/fsrs/next/route.ts @@ -3,33 +3,38 @@ import { prisma } from "@/lib/db" import { buildFsrsCard, getRetrievability } from "@/lib/fsrs" export async function GET(request: NextRequest) { - const childId = request.nextUrl.searchParams.get("childId") - if (!childId) { - return NextResponse.json({ error: "childId required" }, { status: 400 }) - } + try { + const childId = request.nextUrl.searchParams.get("childId") + if (!childId) { + return NextResponse.json({ error: "childId required" }, { status: 400 }) + } - const dueCard = await prisma.fsrsCard.findFirst({ - where: { - childId, - dueAt: { lte: new Date() }, - }, - orderBy: { dueAt: "asc" }, - }) - - if (dueCard) { - const card = buildFsrsCard({ - stability: dueCard.stability, - difficulty: dueCard.difficulty, - reps: dueCard.reps, - lapses: dueCard.lapses, - due: dueCard.dueAt, + const dueCard = await prisma.fsrsCard.findFirst({ + where: { + childId, + dueAt: { lte: new Date() }, + }, + orderBy: { dueAt: "asc" }, }) - return NextResponse.json({ - ...dueCard, - cardId: dueCard.id, - retrievability: getRetrievability(card), - }) - } - return NextResponse.json({ skillCode: null }) + if (dueCard) { + const card = buildFsrsCard({ + stability: dueCard.stability, + difficulty: dueCard.difficulty, + reps: dueCard.reps, + lapses: dueCard.lapses, + due: dueCard.dueAt, + }) + return NextResponse.json({ + ...dueCard, + cardId: dueCard.id, + retrievability: getRetrievability(card), + }) + } + + return NextResponse.json({ skillCode: null }) + } catch (error) { + console.error("[fsrs/next] error:", error) + return NextResponse.json({ error: "Internal error" }, { status: 500 }) + } } diff --git a/app/api/health/route.ts b/app/api/health/route.ts new file mode 100644 index 0000000..5082dc5 --- /dev/null +++ b/app/api/health/route.ts @@ -0,0 +1,18 @@ +import { NextResponse } from "next/server" +import { prisma } from "@/lib/db" + +export async function GET() { + try { + await prisma.$queryRaw`SELECT 1` + return NextResponse.json({ + status: "ok", + timestamp: Date.now(), + database: "connected", + }) + } catch { + return NextResponse.json( + { status: "error", timestamp: Date.now(), database: "disconnected" }, + { status: 503 }, + ) + } +} diff --git a/app/child/lectura/page.tsx b/app/child/lectura/page.tsx index 7d55bb6..3adc180 100644 --- a/app/child/lectura/page.tsx +++ b/app/child/lectura/page.tsx @@ -1,167 +1,12 @@ -"use client" - -import { useState, useCallback, useEffect, useRef } from "react" -import { motion, AnimatePresence } from "framer-motion" -import { GatoMascota } from "@/components/child/gato-mascota" -import NoChildError from "@/components/child/no-child-error" -import TemporizadorVisual from "@/components/child/temporizador-visual" -import ExerciseDispatcher from "@/components/exercises/exercise-dispatcher" -import { speak, stopSpeaking } from "@/lib/speech" -import type { Exercise } from "@/curriculum/types" - -const CHILD_ID_KEY = "edueasy_child_id" - -type PageState = "loading" | "ready" | "complete" | "error" +import ExerciseSession from "@/components/child/exercise-session" export default function LecturaSesion() { - const [pageState, setPageState] = useState("loading") - const [exercise, setExercise] = useState(null) - const [running, setRunning] = useState(true) - const [score, setScore] = useState(0) - const [totalAttempts, setTotalAttempts] = useState(0) - const childIdRef = useRef("") - const startedAtRef = useRef(new Date().toISOString()) - - const getChildId = useCallback(() => { - const id = localStorage.getItem(CHILD_ID_KEY) || "" - childIdRef.current = id - return id - }, []) - - const fetchNext = useCallback(async () => { - const childId = getChildId() - if (!childId) { - setPageState("error") - return null - } - - try { - const res = await fetch(`/api/curriculum/next?childId=${childId}&topic=lectura`) - const data = await res.json() - if (data.done || !data.id) { - setPageState("complete") - return null - } - setExercise(data) - setPageState("ready") - return data - } catch { - setPageState("error") - return null - } - }, [getChildId]) - - useEffect(() => { - fetchNext() - }, [fetchNext]) - - const gradeAttempt = useCallback(async (ex: Exercise, correct: boolean) => { - const childId = childIdRef.current - if (!childId || !ex) return - - setTotalAttempts((t) => t + 1) - if (correct) setScore((s) => s + 1) - - const errorType = !correct - ? ex.errorType || "discriminacion-auditiva" - : null - - try { - await fetch("/api/curriculum/grade", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - childId, - exerciseId: ex.id, - skillCode: ex.skillCode, - correct, - promptLevel: 0, - responseMs: 3000, - errorType, - stage: ex.stage, - }), - }) - } catch { - // silent - } - }, []) - - const handleComplete = useCallback( - async (correct: boolean) => { - if (!exercise) return - await gradeAttempt(exercise, correct) - stopSpeaking() - if (correct) { - await speak("¡Muy bien!") - await new Promise((r) => setTimeout(r, 800)) - } - fetchNext() - }, - [exercise, gradeAttempt, fetchNext], - ) - - const resetSession = useCallback(() => { - setScore(0) - setTotalAttempts(0) - setRunning(true) - startedAtRef.current = new Date().toISOString() - fetchNext() - }, [fetchNext]) - - if (pageState === "loading") { - return ( -
- -
- ) - } - - if (pageState === "error") { - return - } - - if (pageState === "complete") { - return ( -
- -

- Aciertos: {score}/{totalAttempts} -

- -
- ) - } - return ( -
- { - setRunning(false) - setPageState("complete") - }} - running={running} - /> - - - - {exercise && ( - - )} - - -
+ ) } diff --git a/app/child/numeros/page.tsx b/app/child/numeros/page.tsx index b7fbed2..be84537 100644 --- a/app/child/numeros/page.tsx +++ b/app/child/numeros/page.tsx @@ -1,167 +1,12 @@ -"use client" - -import { useState, useCallback, useEffect, useRef } from "react" -import { motion, AnimatePresence } from "framer-motion" -import { GatoMascota } from "@/components/child/gato-mascota" -import NoChildError from "@/components/child/no-child-error" -import TemporizadorVisual from "@/components/child/temporizador-visual" -import ExerciseDispatcher from "@/components/exercises/exercise-dispatcher" -import { speak, stopSpeaking } from "@/lib/speech" - -const CHILD_ID_KEY = "edueasy_child_id" -const META_KEY = "edueasy_child_meta" - -type PageState = "loading" | "ready" | "complete" | "error" +import ExerciseSession from "@/components/child/exercise-session" export default function NumerosSesion() { - const [pageState, setPageState] = useState("loading") - const [exercise, setExercise] = useState(null) - const [running, setRunning] = useState(true) - const [score, setScore] = useState(0) - const [totalAttempts, setTotalAttempts] = useState(0) - const childIdRef = useRef("") - const startedAtRef = useRef(new Date().toISOString()) - - const getChildId = useCallback(() => { - const id = localStorage.getItem(CHILD_ID_KEY) || "" - childIdRef.current = id - return id - }, []) - - const fetchNext = useCallback(async () => { - const childId = getChildId() - if (!childId) { - setPageState("error") - return null - } - - try { - const res = await fetch(`/api/curriculum/next?childId=${childId}&topic=numeros`) - const data = await res.json() - if (data.done || !data.id) { - setPageState("complete") - return null - } - setExercise(data) - setPageState("ready") - return data - } catch { - setPageState("error") - return null - } - }, [getChildId]) - - useEffect(() => { - fetchNext() - }, [fetchNext]) - - const gradeAttempt = useCallback(async (ex: any, correct: boolean) => { - const childId = childIdRef.current - if (!childId || !ex) return - - setTotalAttempts((t) => t + 1) - if (correct) setScore((s) => s + 1) - - const errorType = !correct - ? ex.errorType || "discriminacion-auditiva" - : null - - try { - await fetch("/api/curriculum/grade", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - childId, - exerciseId: ex.id, - skillCode: ex.skillCode, - correct, - promptLevel: 0, - responseMs: 3000, - errorType, - stage: ex.stage, - }), - }) - } catch { - // silent - } - }, []) - - const handleComplete = useCallback( - async (correct: boolean) => { - if (!exercise) return - await gradeAttempt(exercise, correct) - stopSpeaking() - if (correct) { - await speak("¡Muy bien!") - await new Promise((r) => setTimeout(r, 800)) - } - fetchNext() - }, - [exercise, gradeAttempt, fetchNext], - ) - - const resetSession = useCallback(() => { - setScore(0) - setTotalAttempts(0) - setRunning(true) - startedAtRef.current = new Date().toISOString() - fetchNext() - }, [fetchNext]) - - if (pageState === "loading") { - return ( -
- -
- ) - } - - if (pageState === "error") { - return - } - - if (pageState === "complete") { - return ( -
- -

- Aciertos: {score}/{totalAttempts} -

- -
- ) - } - return ( -
- { - setRunning(false) - setPageState("complete") - }} - running={running} - /> - - - - {exercise && ( - - )} - - -
+ ) } diff --git a/app/child/page.tsx b/app/child/page.tsx index e1ff5de..6102b39 100644 --- a/app/child/page.tsx +++ b/app/child/page.tsx @@ -14,8 +14,17 @@ export default function ChildHome() { const router = useRouter() return ( -
- +
+ + +
+
{activities.map((a) => ( @@ -29,6 +38,7 @@ export default function ChildHome() { ))}
+
) } diff --git a/app/francesca/page.tsx b/app/francesca/page.tsx index 68a0e82..778750d 100644 --- a/app/francesca/page.tsx +++ b/app/francesca/page.tsx @@ -39,8 +39,17 @@ export default function FrancescaHome() { } return ( -
- +
+ + +
+ {!paired ? (
@@ -71,6 +80,7 @@ export default function FrancescaHome() { ))}
)} +
) } diff --git a/app/sebastian/page.tsx b/app/sebastian/page.tsx index b70c102..38426f5 100644 --- a/app/sebastian/page.tsx +++ b/app/sebastian/page.tsx @@ -41,8 +41,17 @@ export default function SebastianHome() { } return ( -
- +
+ + +
+ {!paired ? (
@@ -73,6 +82,7 @@ export default function SebastianHome() { ))}
)} +
) } diff --git a/components/child/exercise-session.tsx b/components/child/exercise-session.tsx index 26bb3e2..9b5e328 100644 --- a/components/child/exercise-session.tsx +++ b/components/child/exercise-session.tsx @@ -1,6 +1,7 @@ "use client" import { useState, useCallback, useEffect, useRef } from "react" +import { useRouter } from "next/navigation" import { motion, AnimatePresence } from "framer-motion" import { GatoMascota } from "@/components/child/gato-mascota" import NoChildError from "@/components/child/no-child-error" @@ -20,7 +21,16 @@ interface Props { profile?: string } +function profileHome(profile?: string) { + switch (profile?.toUpperCase()) { + case "FRANCESCA": return "/francesca" + case "SEBASTIAN": return "/sebastian" + default: return "/child" + } +} + export default function ExerciseSession({ topic, loadingMessage, completeMessage, profile }: Props) { + const router = useRouter() const [pageState, setPageState] = useState("loading") const [exercise, setExercise] = useState(null) const [running, setRunning] = useState(true) @@ -30,6 +40,21 @@ export default function ExerciseSession({ topic, loadingMessage, completeMessage const exerciseStartedAt = useRef(Date.now()) const startedAtRef = useRef(new Date().toISOString()) + const backHref = profileHome(profile) + + const BackBar = () => ( + + ) + const getChildId = useCallback(() => { const id = localStorage.getItem(CHILD_ID_KEY) || "" childIdRef.current = id @@ -125,58 +150,74 @@ export default function ExerciseSession({ topic, loadingMessage, completeMessage if (pageState === "loading") { return ( -
- +
+ +
+ +
) } if (pageState === "error") { - return + return ( +
+ +
+ +
+
+ ) } if (pageState === "complete") { return ( -
- -

- Aciertos: {score}/{totalAttempts} -

- +
+ +
+ +

+ Aciertos: {score}/{totalAttempts} +

+ +
) } return ( -
- { - setRunning(false) - setPageState("complete") - }} - running={running} - /> +
+ +
+ { + setRunning(false) + setPageState("complete") + }} + running={running} + /> - - - {exercise && ( - - )} - - + + + {exercise && ( + + )} + + +
) } diff --git a/docker-compose.yml b/docker-compose.yml index 9cd1967..5f25180 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -30,6 +30,11 @@ services: HOSTNAME: 0.0.0.0 ports: - "3000:3000" + healthcheck: + test: ["CMD-SHELL", "wget -qO- http://localhost:3000/api/health || exit 1"] + interval: 30s + timeout: 5s + retries: 3 command: sh -c "npx prisma db push --skip-generate && node server.js" volumes: diff --git a/e2e/api-curriculum.spec.ts b/e2e/api-curriculum.spec.ts index ec65c99..0f21bdd 100644 --- a/e2e/api-curriculum.spec.ts +++ b/e2e/api-curriculum.spec.ts @@ -29,11 +29,11 @@ test.describe("Curriculum API", () => { expect(json.error).toContain("childId") }) - test("curriculum/next accepts childId param", async ({ request }) => { - const res = await request.get("/api/curriculum/next?childId=any&topic=lectura") - expect(res.status()).toBe(200) + test("curriculum/next rejects non-existent childId", async ({ request }) => { + const res = await request.get("/api/curriculum/next?childId=nonexistent&topic=lectura") + expect(res.status()).toBe(404) const json = await res.json() - expect(json).toBeDefined() + expect(json.error).toContain("not found") }) test("curriculum/grade missing fields returns 400", async ({ request }) => { @@ -61,4 +61,12 @@ test.describe("Curriculum API", () => { const json = await res.json() expect(json.error).toContain("childId") }) + + test("health endpoint returns ok status", async ({ request }) => { + const res = await request.get("/api/health") + expect(res.status()).toBe(200) + const json = await res.json() + expect(json.status).toBe("ok") + expect(json.timestamp).toBeDefined() + }) }) diff --git a/fase5.md b/fase5.md new file mode 100644 index 0000000..8500eab --- /dev/null +++ b/fase5.md @@ -0,0 +1,115 @@ +# FASE 5 — Mega Plan: Security, Infrastructure, Quality + +> **Análisis completo del sistema**: 155 archivos TS/TSX, 67 archivos de currículum, 15 API routes, 22 páginas, 31 componentes. + +--- + +## 0. Hallazgos Críticos del Análisis + +| # | Severidad | Issue | Estado actual | +|---|-----------|-------|---------------| +| 1 | 🔴 CRÍTICO | **15 APIs sin autenticación** — cualquiera accede/modifica datos de children | `better-auth` instalado pero NUNCA enforceado | +| 2 | 🟠 ALTO | **5 APIs sin try/catch** — Prisma errors → unhandled rejections → crash | `children`, `curriculum/next`, `dashboard/summary`, `fsrs/next` | +| 3 | 🟠 ALTO | **Sin .dockerignore** — Docker copia 777MB de node_modules al contexto | build lento, imagen enorme | +| 4 | 🟡 MEDIO | **Sin .env.example** — usuarios no saben qué variables configurar | | +| 5 | 🟡 MEDIO | **Sin README.md** — cero documentación en raíz del proyecto | | +| 6 | 🟡 MEDIO | **Sin /api/health** — Docker no puede verificar si la app responde | | +| 7 | 🟡 MEDIO | **ESLint config deprecated** — produce warnings en build | FlatCompat con opciones removidas | +| 8 | 🟢 BAJO | **Sin rate limiting en TTS** — endpoint CPU-intensive sin protección | | +| 9 | 🟢 BAJO | **Sin input validation** — tipos/longitudes no validados antes de Prisma | | + +--- + +## 1. Plan de Ejecución + +### FASE 5.1 — Seguridad: Device Auth Middleware (CRÍTICO) +- Crear middleware de API que valide device pairing +- Las APIs de children/curriculum requieren `childId` validado contra `Device.deviceFingerprint` +- Las APIs de debug ya están protegidas (NODE_ENV guard de fase 4) +- Las APIs de auth (better-auth) se excluyen del middleware + +### FASE 5.2 — Error Handling Consistente +- Envolver TODAS las APIs en try/catch con respuesta 500 estandarizada +- Log de errores con contexto (ruta, childId, timestamp) + +### FASE 5.3 — Infraestructura Faltante +- `.env.example` con todas las variables documentadas +- `.dockerignore` para excluir node_modules, .next, .git, e2e, *.md +- `README.md` con descripción del proyecto y links +- `app/api/health/route.ts` — endpoint de health check simple + +### FASE 5.4 — ESLint Fix +- Simplificar `eslint.config.mjs` sin FlatCompat deprecated +- Verificar que `next lint` pasa sin warnings + +### FASE 5.5 — Docker Hardening +- `.dockerignore` reduce contexto de build +- Health check en docker-compose.yml +- `.env.example` referenciado en INSTALL.md + +### FASE 5.6 — Verificación Final +- TypeScript compila +- Build pasa +- 61+ E2E tests pasan +- Commit + push + +--- + +## 2. Detalles Técnicos + +### 2.1 API Auth Strategy + +El sistema tiene 2 tipos de acceso: +1. **Child device** (iPad): ya emparejado vía pairing code, tiene `deviceFingerprint` en localStorage +2. **Parent**: autenticado vía better-auth (email/password) + +Para no romper el flujo existente, el middleware validará: +- `/api/curriculum/*`, `/api/dashboard/*`, `/api/milestones`, `/api/sessions/*`: requieren `childId` que tenga un Device asociado +- `/api/children`, `/api/pairing`: requieren parent session (better-auth) o device fingerprint +- `/api/tts`: sin auth (endpoint público, ya tiene límite de 200 chars) +- `/api/debug/*`: ya protegidas (NODE_ENV) +- `/api/auth/*`: manejado por better-auth + +Implementación pragmática: en lugar de middleware complejo, añadir validación de `childId` directamente en cada API que lo recibe — verificar que el child existe antes de devolver datos. + +### 2.2 .dockerignore + +``` +node_modules +.next +.git +e2e +*.md +.env* +docker-compose.yml +Dockerfile +.playwright +``` + +### 2.3 Health Endpoint + +```typescript +// app/api/health/route.ts +export async function GET() { + return NextResponse.json({ status: "ok", timestamp: Date.now() }) +} +``` + +### 2.4 ESLint Fix + +Reemplazar `FlatCompat` con config flat nativa de ESLint 9. + +--- + +## 3. Ejecución (checklist) + +- [ ] 5.1 Crear childId validation helper + aplicar a APIs +- [ ] 5.2 Añadir try/catch a 5 APIs sin manejo de errores +- [ ] 5.3 Crear .env.example +- [ ] 5.4 Crear .dockerignore +- [ ] 5.5 Crear README.md +- [ ] 5.6 Crear /api/health endpoint +- [ ] 5.7 Fix ESLint config +- [ ] 5.8 Actualizar docker-compose.yml con healthcheck +- [ ] 5.9 Verificar: tsc + build + tests +- [ ] 5.10 Commit + push diff --git a/lib/validate-child.ts b/lib/validate-child.ts new file mode 100644 index 0000000..c540efd --- /dev/null +++ b/lib/validate-child.ts @@ -0,0 +1,19 @@ +import { prisma } from "@/lib/db" + +/** + * Validates that a childId exists in the database. + * Returns the child record or null. + * Use this in API routes to prevent access to non-existent children. + */ +export async function validateChild(childId: string | null) { + if (!childId) return null + try { + const child = await prisma.child.findUnique({ + where: { id: childId }, + select: { id: true, name: true, profile: true, familyId: true }, + }) + return child + } catch { + return null + } +}