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:
@@ -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
|
||||
@@ -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"
|
||||
@@ -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
@@ -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 })
|
||||
}
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
|
||||
@@ -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
@@ -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 })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user