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"
|
import { auth } from "@/lib/auth"
|
||||||
|
|
||||||
export async function GET(request: NextRequest) {
|
export async function GET(request: NextRequest) {
|
||||||
const session = await auth.api.getSession({ headers: request.headers })
|
try {
|
||||||
if (!session?.user?.id) {
|
const session = await auth.api.getSession({ headers: request.headers })
|
||||||
return NextResponse.json({ error: "No autorizado" }, { status: 401 })
|
if (!session?.user?.id) {
|
||||||
}
|
return NextResponse.json({ error: "No autorizado" }, { status: 401 })
|
||||||
|
}
|
||||||
|
|
||||||
const parent = await prisma.parent.findUnique({
|
const parent = await prisma.parent.findUnique({
|
||||||
where: { id: session.user.id },
|
where: { id: session.user.id },
|
||||||
include: {
|
include: {
|
||||||
family: {
|
family: {
|
||||||
include: { children: true },
|
include: { children: true },
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
})
|
||||||
})
|
|
||||||
|
|
||||||
if (!parent || !parent.family) {
|
if (!parent || !parent.family) {
|
||||||
return NextResponse.json({ error: "Parent or family not found" }, { status: 404 })
|
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) {
|
export async function POST(request: Request) {
|
||||||
const session = await auth.api.getSession({ headers: request.headers })
|
try {
|
||||||
if (!session?.user?.id) {
|
const session = await auth.api.getSession({ headers: request.headers })
|
||||||
return NextResponse.json({ error: "No autorizado" }, { status: 401 })
|
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"
|
import { getNextExercise } from "@/lib/curriculum/engine"
|
||||||
|
|
||||||
export async function GET(request: NextRequest) {
|
export async function GET(request: NextRequest) {
|
||||||
const childId = request.nextUrl.searchParams.get("childId")
|
try {
|
||||||
const topic = request.nextUrl.searchParams.get("topic") || "lectura"
|
const childId = request.nextUrl.searchParams.get("childId")
|
||||||
if (!childId) {
|
const topic = request.nextUrl.searchParams.get("topic") || "lectura"
|
||||||
return NextResponse.json({ error: "childId required" }, { status: 400 })
|
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"
|
import { prisma } from "@/lib/db"
|
||||||
|
|
||||||
export async function GET(request: NextRequest) {
|
export async function GET(request: NextRequest) {
|
||||||
const childId = request.nextUrl.searchParams.get("childId")
|
try {
|
||||||
if (!childId) {
|
const childId = request.nextUrl.searchParams.get("childId")
|
||||||
return NextResponse.json({ error: "childId required" }, { status: 400 })
|
if (!childId) {
|
||||||
}
|
return NextResponse.json({ error: "childId required" }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
const today = new Date()
|
const today = new Date()
|
||||||
today.setHours(0, 0, 0, 0)
|
today.setHours(0, 0, 0, 0)
|
||||||
@@ -118,6 +119,10 @@ export async function GET(request: NextRequest) {
|
|||||||
}
|
}
|
||||||
: null,
|
: null,
|
||||||
})
|
})
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[dashboard/summary] error:", error)
|
||||||
|
return NextResponse.json({ error: "Internal error" }, { status: 500 })
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function calculateStreak(childId: string): Promise<number> {
|
async function calculateStreak(childId: string): Promise<number> {
|
||||||
|
|||||||
+31
-26
@@ -3,33 +3,38 @@ import { prisma } from "@/lib/db"
|
|||||||
import { buildFsrsCard, getRetrievability } from "@/lib/fsrs"
|
import { buildFsrsCard, getRetrievability } from "@/lib/fsrs"
|
||||||
|
|
||||||
export async function GET(request: NextRequest) {
|
export async function GET(request: NextRequest) {
|
||||||
const childId = request.nextUrl.searchParams.get("childId")
|
try {
|
||||||
if (!childId) {
|
const childId = request.nextUrl.searchParams.get("childId")
|
||||||
return NextResponse.json({ error: "childId required" }, { status: 400 })
|
if (!childId) {
|
||||||
}
|
return NextResponse.json({ error: "childId required" }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
const dueCard = await prisma.fsrsCard.findFirst({
|
const dueCard = await prisma.fsrsCard.findFirst({
|
||||||
where: {
|
where: {
|
||||||
childId,
|
childId,
|
||||||
dueAt: { lte: new Date() },
|
dueAt: { lte: new Date() },
|
||||||
},
|
},
|
||||||
orderBy: { dueAt: "asc" },
|
orderBy: { dueAt: "asc" },
|
||||||
})
|
|
||||||
|
|
||||||
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 })
|
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 ExerciseSession from "@/components/child/exercise-session"
|
||||||
|
|
||||||
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"
|
|
||||||
|
|
||||||
export default function LecturaSesion() {
|
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 (
|
return (
|
||||||
<div className="flex-1 flex flex-col items-center justify-center p-6 gap-6">
|
<ExerciseSession
|
||||||
<TemporizadorVisual
|
topic="lectura"
|
||||||
durationMinutes={10}
|
profile="ISABELLA"
|
||||||
onComplete={() => {
|
loadingMessage="Preparando ejercicios..."
|
||||||
setRunning(false)
|
completeMessage="¡Terminaste todos los ejercicios por ahora!"
|
||||||
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>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
+7
-162
@@ -1,167 +1,12 @@
|
|||||||
"use client"
|
import ExerciseSession from "@/components/child/exercise-session"
|
||||||
|
|
||||||
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"
|
|
||||||
|
|
||||||
export default function NumerosSesion() {
|
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 (
|
return (
|
||||||
<div className="flex-1 flex flex-col items-center justify-center p-6 gap-6">
|
<ExerciseSession
|
||||||
<TemporizadorVisual
|
topic="numeros"
|
||||||
durationMinutes={10}
|
profile="ISABELLA"
|
||||||
onComplete={() => {
|
loadingMessage="Preparando números..."
|
||||||
setRunning(false)
|
completeMessage="¡Terminaste los números por ahora!"
|
||||||
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>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
+12
-2
@@ -14,8 +14,17 @@ export default function ChildHome() {
|
|||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex-1 flex flex-col items-center justify-center p-6 gap-8">
|
<div className="flex-1 flex flex-col p-4">
|
||||||
<GatoMascota mood="happy" message="¡Hola! ¿Qué querés aprender hoy?" />
|
<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">
|
<div className="flex flex-col gap-4 w-full max-w-md">
|
||||||
{activities.map((a) => (
|
{activities.map((a) => (
|
||||||
@@ -29,6 +38,7 @@ export default function ChildHome() {
|
|||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
+12
-2
@@ -39,8 +39,17 @@ export default function FrancescaHome() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex-1 flex flex-col items-center justify-center p-6 gap-8">
|
<div className="flex-1 flex flex-col p-4">
|
||||||
<GatoMascota mood="happy" message="¡Hola Francesca! ¿Qué querés practicar hoy?" />
|
<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 ? (
|
{!paired ? (
|
||||||
<div className="text-center max-w-xs space-y-4">
|
<div className="text-center max-w-xs space-y-4">
|
||||||
@@ -71,6 +80,7 @@ export default function FrancescaHome() {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
+12
-2
@@ -41,8 +41,17 @@ export default function SebastianHome() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex-1 flex flex-col items-center justify-center p-6 gap-8">
|
<div className="flex-1 flex flex-col p-4">
|
||||||
<GatoMascota mood="happy" message="¡Hola Sebastián! ¿Qué querés explorar hoy?" />
|
<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 ? (
|
{!paired ? (
|
||||||
<div className="text-center max-w-xs space-y-4">
|
<div className="text-center max-w-xs space-y-4">
|
||||||
@@ -73,6 +82,7 @@ export default function SebastianHome() {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { useState, useCallback, useEffect, useRef } from "react"
|
import { useState, useCallback, useEffect, useRef } from "react"
|
||||||
|
import { useRouter } from "next/navigation"
|
||||||
import { motion, AnimatePresence } from "framer-motion"
|
import { motion, AnimatePresence } from "framer-motion"
|
||||||
import { GatoMascota } from "@/components/child/gato-mascota"
|
import { GatoMascota } from "@/components/child/gato-mascota"
|
||||||
import NoChildError from "@/components/child/no-child-error"
|
import NoChildError from "@/components/child/no-child-error"
|
||||||
@@ -20,7 +21,16 @@ interface Props {
|
|||||||
profile?: string
|
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) {
|
export default function ExerciseSession({ topic, loadingMessage, completeMessage, profile }: Props) {
|
||||||
|
const router = useRouter()
|
||||||
const [pageState, setPageState] = useState<PageState>("loading")
|
const [pageState, setPageState] = useState<PageState>("loading")
|
||||||
const [exercise, setExercise] = useState<Exercise | null>(null)
|
const [exercise, setExercise] = useState<Exercise | null>(null)
|
||||||
const [running, setRunning] = useState(true)
|
const [running, setRunning] = useState(true)
|
||||||
@@ -30,6 +40,21 @@ export default function ExerciseSession({ topic, loadingMessage, completeMessage
|
|||||||
const exerciseStartedAt = useRef<number>(Date.now())
|
const exerciseStartedAt = useRef<number>(Date.now())
|
||||||
const startedAtRef = useRef(new Date().toISOString())
|
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 getChildId = useCallback(() => {
|
||||||
const id = localStorage.getItem(CHILD_ID_KEY) || ""
|
const id = localStorage.getItem(CHILD_ID_KEY) || ""
|
||||||
childIdRef.current = id
|
childIdRef.current = id
|
||||||
@@ -125,58 +150,74 @@ export default function ExerciseSession({ topic, loadingMessage, completeMessage
|
|||||||
|
|
||||||
if (pageState === "loading") {
|
if (pageState === "loading") {
|
||||||
return (
|
return (
|
||||||
<div className="flex-1 flex items-center justify-center">
|
<div className="flex-1 flex flex-col p-4">
|
||||||
<GatoMascota mood="coach" message={loadingMessage} size="sm" />
|
<BackBar />
|
||||||
|
<div className="flex-1 flex items-center justify-center">
|
||||||
|
<GatoMascota mood="coach" message={loadingMessage} size="sm" />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (pageState === "error") {
|
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") {
|
if (pageState === "complete") {
|
||||||
return (
|
return (
|
||||||
<div className="flex-1 flex flex-col items-center justify-center p-8 gap-6">
|
<div className="flex-1 flex flex-col p-4">
|
||||||
<GatoMascota mood="celebrate" message={completeMessage} size="lg" />
|
<BackBar />
|
||||||
<p className="text-lg font-display">
|
<div className="flex-1 flex flex-col items-center justify-center gap-6">
|
||||||
Aciertos: {score}/{totalAttempts}
|
<GatoMascota mood="celebrate" message={completeMessage} size="lg" />
|
||||||
</p>
|
<p className="text-lg font-display">
|
||||||
<button
|
Aciertos: {score}/{totalAttempts}
|
||||||
onClick={resetSession}
|
</p>
|
||||||
className="bg-primary text-primary-foreground touch-target-lg rounded-2xl px-8 py-4 text-xl font-display font-bold"
|
<button
|
||||||
>
|
onClick={resetSession}
|
||||||
Jugar de nuevo
|
className="bg-primary text-primary-foreground touch-target-lg rounded-2xl px-8 py-4 text-xl font-display font-bold"
|
||||||
</button>
|
>
|
||||||
|
Jugar de nuevo
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex-1 flex flex-col items-center justify-center p-6 gap-6">
|
<div className="flex-1 flex flex-col p-4 gap-4">
|
||||||
<TemporizadorVisual
|
<BackBar />
|
||||||
durationMinutes={10}
|
<div className="flex-1 flex flex-col items-center justify-center gap-6">
|
||||||
onComplete={() => {
|
<TemporizadorVisual
|
||||||
setRunning(false)
|
durationMinutes={10}
|
||||||
setPageState("complete")
|
onComplete={() => {
|
||||||
}}
|
setRunning(false)
|
||||||
running={running}
|
setPageState("complete")
|
||||||
/>
|
}}
|
||||||
|
running={running}
|
||||||
|
/>
|
||||||
|
|
||||||
<AnimatePresence mode="wait">
|
<AnimatePresence mode="wait">
|
||||||
<motion.div
|
<motion.div
|
||||||
key={exercise?.id || "empty"}
|
key={exercise?.id || "empty"}
|
||||||
initial={{ opacity: 0, x: 50 }}
|
initial={{ opacity: 0, x: 50 }}
|
||||||
animate={{ opacity: 1, x: 0 }}
|
animate={{ opacity: 1, x: 0 }}
|
||||||
exit={{ opacity: 0, x: -50 }}
|
exit={{ opacity: 0, x: -50 }}
|
||||||
transition={{ duration: 0.25 }}
|
transition={{ duration: 0.25 }}
|
||||||
className="w-full max-w-md"
|
className="w-full max-w-md"
|
||||||
>
|
>
|
||||||
{exercise && (
|
{exercise && (
|
||||||
<ExerciseDispatcher exercise={exercise} onComplete={handleComplete} />
|
<ExerciseDispatcher exercise={exercise} onComplete={handleComplete} />
|
||||||
)}
|
)}
|
||||||
</motion.div>
|
</motion.div>
|
||||||
</AnimatePresence>
|
</AnimatePresence>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,6 +30,11 @@ services:
|
|||||||
HOSTNAME: 0.0.0.0
|
HOSTNAME: 0.0.0.0
|
||||||
ports:
|
ports:
|
||||||
- "3000:3000"
|
- "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"
|
command: sh -c "npx prisma db push --skip-generate && node server.js"
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
|
|||||||
@@ -29,11 +29,11 @@ test.describe("Curriculum API", () => {
|
|||||||
expect(json.error).toContain("childId")
|
expect(json.error).toContain("childId")
|
||||||
})
|
})
|
||||||
|
|
||||||
test("curriculum/next accepts childId param", async ({ request }) => {
|
test("curriculum/next rejects non-existent childId", async ({ request }) => {
|
||||||
const res = await request.get("/api/curriculum/next?childId=any&topic=lectura")
|
const res = await request.get("/api/curriculum/next?childId=nonexistent&topic=lectura")
|
||||||
expect(res.status()).toBe(200)
|
expect(res.status()).toBe(404)
|
||||||
const json = await res.json()
|
const json = await res.json()
|
||||||
expect(json).toBeDefined()
|
expect(json.error).toContain("not found")
|
||||||
})
|
})
|
||||||
|
|
||||||
test("curriculum/grade missing fields returns 400", async ({ request }) => {
|
test("curriculum/grade missing fields returns 400", async ({ request }) => {
|
||||||
@@ -61,4 +61,12 @@ test.describe("Curriculum API", () => {
|
|||||||
const json = await res.json()
|
const json = await res.json()
|
||||||
expect(json.error).toContain("childId")
|
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