feat: fase 5 — botones volver, seguridad APIs, infraestructura

- Botón 'Volver' en todas las sesiones de ejercicios (ExerciseSession)
- Botón 'Cambiar perfil' en las 3 home pages (Isabella/Francesca/Sebastián)
- Páginas inline de Isabella migradas a ExerciseSession (elimina código duplicado)
- APIs con try/catch: curriculum/next, dashboard/summary, fsrs/next, children
- Validación childId en curriculum/next (404 si no existe)
- /api/health endpoint para Docker healthcheck
- .env.example, .dockerignore, README.md creados
- docker-compose.yml con healthcheck en app service
- Test E2E: health endpoint + childId validation
- 62/62 tests pasan, TS exit 0
This commit is contained in:
renato97
2026-07-26 02:00:27 -03:00
parent ce7cb27290
commit 411f235f75
18 changed files with 470 additions and 457 deletions
+17
View File
@@ -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
+10
View File
@@ -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"
+31
View File
@@ -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
+53 -43
View File
@@ -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 })
}
+22 -13
View File
@@ -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 })
}
+9 -4
View File
@@ -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<number> {
+31 -26
View File
@@ -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 })
}
}
+18
View File
@@ -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 },
)
}
}
+7 -162
View File
@@ -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<PageState>("loading")
const [exercise, setExercise] = useState<Exercise | null>(null)
const [running, setRunning] = useState(true)
const [score, setScore] = useState(0)
const [totalAttempts, setTotalAttempts] = useState(0)
const childIdRef = useRef<string>("")
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 (
<div className="flex-1 flex items-center justify-center">
<GatoMascota mood="coach" message="Preparando ejercicios..." size="sm" />
</div>
)
}
if (pageState === "error") {
return <NoChildError onRetry={resetSession} />
}
if (pageState === "complete") {
return (
<div className="flex-1 flex flex-col items-center justify-center p-8 gap-6">
<GatoMascota mood="celebrate" message="¡Terminaste todos los ejercicios por ahora!" size="lg" />
<p className="text-lg font-display">
Aciertos: {score}/{totalAttempts}
</p>
<button
onClick={resetSession}
className="bg-primary text-primary-foreground touch-target-lg rounded-2xl px-8 py-4 text-xl font-display font-bold"
>
Jugar de nuevo
</button>
</div>
)
}
return (
<div className="flex-1 flex flex-col items-center justify-center p-6 gap-6">
<TemporizadorVisual
durationMinutes={10}
onComplete={() => {
setRunning(false)
setPageState("complete")
}}
running={running}
/>
<AnimatePresence mode="wait">
<motion.div
key={exercise?.id || "empty"}
initial={{ opacity: 0, x: 50 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: -50 }}
transition={{ duration: 0.25 }}
className="w-full max-w-md"
>
{exercise && (
<ExerciseDispatcher exercise={exercise} onComplete={handleComplete} />
)}
</motion.div>
</AnimatePresence>
</div>
<ExerciseSession
topic="lectura"
profile="ISABELLA"
loadingMessage="Preparando ejercicios..."
completeMessage="¡Terminaste todos los ejercicios por ahora!"
/>
)
}
+7 -162
View File
@@ -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<PageState>("loading")
const [exercise, setExercise] = useState<any | null>(null)
const [running, setRunning] = useState(true)
const [score, setScore] = useState(0)
const [totalAttempts, setTotalAttempts] = useState(0)
const childIdRef = useRef<string>("")
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 (
<div className="flex-1 flex items-center justify-center">
<GatoMascota mood="coach" message="Preparando ejercicios de números..." size="sm" />
</div>
)
}
if (pageState === "error") {
return <NoChildError onRetry={resetSession} />
}
if (pageState === "complete") {
return (
<div className="flex-1 flex flex-col items-center justify-center p-8 gap-6">
<GatoMascota mood="celebrate" message="¡Terminaste todos los ejercicios de números por ahora!" size="lg" />
<p className="text-lg font-display">
Aciertos: {score}/{totalAttempts}
</p>
<button
onClick={resetSession}
className="bg-primary text-primary-foreground touch-target-lg rounded-2xl px-8 py-4 text-xl font-display font-bold"
>
Jugar de nuevo
</button>
</div>
)
}
return (
<div className="flex-1 flex flex-col items-center justify-center p-6 gap-6">
<TemporizadorVisual
durationMinutes={10}
onComplete={() => {
setRunning(false)
setPageState("complete")
}}
running={running}
/>
<AnimatePresence mode="wait">
<motion.div
key={exercise?.id || "empty"}
initial={{ opacity: 0, x: 50 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: -50 }}
transition={{ duration: 0.25 }}
className="w-full max-w-md"
>
{exercise && (
<ExerciseDispatcher exercise={exercise} onComplete={handleComplete} />
)}
</motion.div>
</AnimatePresence>
</div>
<ExerciseSession
topic="numeros"
profile="ISABELLA"
loadingMessage="Preparando números..."
completeMessage="¡Terminaste los números por ahora!"
/>
)
}
+12 -2
View File
@@ -14,8 +14,17 @@ export default function ChildHome() {
const router = useRouter()
return (
<div className="flex-1 flex flex-col items-center justify-center p-6 gap-8">
<GatoMascota mood="happy" message="¡Hola! ¿Qué querés aprender hoy?" />
<div className="flex-1 flex flex-col p-4">
<button
onClick={() => router.push("/")}
className="self-start flex items-center gap-2 bg-surface text-foreground border border-border rounded-xl px-4 py-2 text-base font-display font-semibold shadow-sm active:scale-95 transition-transform mb-4"
>
<span className="text-xl"></span>
<span>Cambiar perfil</span>
</button>
<div className="flex-1 flex flex-col items-center justify-center gap-8">
<GatoMascota mood="happy" message="¡Hola! ¿Qué querés aprender hoy?" />
<div className="flex flex-col gap-4 w-full max-w-md">
{activities.map((a) => (
@@ -29,6 +38,7 @@ export default function ChildHome() {
</button>
))}
</div>
</div>
</div>
)
}
+12 -2
View File
@@ -39,8 +39,17 @@ export default function FrancescaHome() {
}
return (
<div className="flex-1 flex flex-col items-center justify-center p-6 gap-8">
<GatoMascota mood="happy" message="¡Hola Francesca! ¿Qué querés practicar hoy?" />
<div className="flex-1 flex flex-col p-4">
<button
onClick={() => router.push("/")}
className="self-start flex items-center gap-2 bg-surface text-foreground border border-border rounded-xl px-4 py-2 text-base font-display font-semibold shadow-sm active:scale-95 transition-transform mb-4"
>
<span className="text-xl"></span>
<span>Cambiar perfil</span>
</button>
<div className="flex-1 flex flex-col items-center justify-center gap-8">
<GatoMascota mood="happy" message="¡Hola Francesca! ¿Qué querés practicar hoy?" />
{!paired ? (
<div className="text-center max-w-xs space-y-4">
@@ -71,6 +80,7 @@ export default function FrancescaHome() {
))}
</div>
)}
</div>
</div>
)
}
+12 -2
View File
@@ -41,8 +41,17 @@ export default function SebastianHome() {
}
return (
<div className="flex-1 flex flex-col items-center justify-center p-6 gap-8">
<GatoMascota mood="happy" message="¡Hola Sebastián! ¿Qué querés explorar hoy?" />
<div className="flex-1 flex flex-col p-4">
<button
onClick={() => router.push("/")}
className="self-start flex items-center gap-2 bg-surface text-foreground border border-border rounded-xl px-4 py-2 text-base font-display font-semibold shadow-sm active:scale-95 transition-transform mb-4"
>
<span className="text-xl"></span>
<span>Cambiar perfil</span>
</button>
<div className="flex-1 flex flex-col items-center justify-center gap-8">
<GatoMascota mood="happy" message="¡Hola Sebastián! ¿Qué querés explorar hoy?" />
{!paired ? (
<div className="text-center max-w-xs space-y-4">
@@ -73,6 +82,7 @@ export default function SebastianHome() {
))}
</div>
)}
</div>
</div>
)
}
+78 -37
View File
@@ -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<PageState>("loading")
const [exercise, setExercise] = useState<Exercise | null>(null)
const [running, setRunning] = useState(true)
@@ -30,6 +40,21 @@ export default function ExerciseSession({ topic, loadingMessage, completeMessage
const exerciseStartedAt = useRef<number>(Date.now())
const startedAtRef = useRef(new Date().toISOString())
const backHref = profileHome(profile)
const BackBar = () => (
<button
onClick={() => {
stopSpeaking()
router.push(backHref)
}}
className="self-start flex items-center gap-2 bg-surface text-foreground border border-border rounded-xl px-4 py-2 text-base font-display font-semibold shadow-sm active:scale-95 transition-transform"
>
<span className="text-xl"></span>
<span>Volver</span>
</button>
)
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 (
<div className="flex-1 flex items-center justify-center">
<GatoMascota mood="coach" message={loadingMessage} size="sm" />
<div className="flex-1 flex flex-col p-4">
<BackBar />
<div className="flex-1 flex items-center justify-center">
<GatoMascota mood="coach" message={loadingMessage} size="sm" />
</div>
</div>
)
}
if (pageState === "error") {
return <NoChildError onRetry={resetSession} />
return (
<div className="flex-1 flex flex-col p-4">
<BackBar />
<div className="flex-1 flex items-center justify-center">
<NoChildError onRetry={resetSession} />
</div>
</div>
)
}
if (pageState === "complete") {
return (
<div className="flex-1 flex flex-col items-center justify-center p-8 gap-6">
<GatoMascota mood="celebrate" message={completeMessage} size="lg" />
<p className="text-lg font-display">
Aciertos: {score}/{totalAttempts}
</p>
<button
onClick={resetSession}
className="bg-primary text-primary-foreground touch-target-lg rounded-2xl px-8 py-4 text-xl font-display font-bold"
>
Jugar de nuevo
</button>
<div className="flex-1 flex flex-col p-4">
<BackBar />
<div className="flex-1 flex flex-col items-center justify-center gap-6">
<GatoMascota mood="celebrate" message={completeMessage} size="lg" />
<p className="text-lg font-display">
Aciertos: {score}/{totalAttempts}
</p>
<button
onClick={resetSession}
className="bg-primary text-primary-foreground touch-target-lg rounded-2xl px-8 py-4 text-xl font-display font-bold"
>
Jugar de nuevo
</button>
</div>
</div>
)
}
return (
<div className="flex-1 flex flex-col items-center justify-center p-6 gap-6">
<TemporizadorVisual
durationMinutes={10}
onComplete={() => {
setRunning(false)
setPageState("complete")
}}
running={running}
/>
<div className="flex-1 flex flex-col p-4 gap-4">
<BackBar />
<div className="flex-1 flex flex-col items-center justify-center gap-6">
<TemporizadorVisual
durationMinutes={10}
onComplete={() => {
setRunning(false)
setPageState("complete")
}}
running={running}
/>
<AnimatePresence mode="wait">
<motion.div
key={exercise?.id || "empty"}
initial={{ opacity: 0, x: 50 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: -50 }}
transition={{ duration: 0.25 }}
className="w-full max-w-md"
>
{exercise && (
<ExerciseDispatcher exercise={exercise} onComplete={handleComplete} />
)}
</motion.div>
</AnimatePresence>
<AnimatePresence mode="wait">
<motion.div
key={exercise?.id || "empty"}
initial={{ opacity: 0, x: 50 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: -50 }}
transition={{ duration: 0.25 }}
className="w-full max-w-md"
>
{exercise && (
<ExerciseDispatcher exercise={exercise} onComplete={handleComplete} />
)}
</motion.div>
</AnimatePresence>
</div>
</div>
)
}
+5
View File
@@ -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:
+12 -4
View File
@@ -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()
})
})
+115
View File
@@ -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
+19
View File
@@ -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
}
}