diff --git a/Dockerfile b/Dockerfile
index d988054..12038f4 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -1,11 +1,13 @@
# Stage 1: Install dependencies
FROM node:22-alpine AS deps
+RUN apk add --no-cache espeak-ng
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
# Stage 2: Build
FROM node:22-alpine AS builder
+RUN apk add --no-cache espeak-ng
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
@@ -15,18 +17,21 @@ RUN npm run build
# Stage 3: Production
FROM node:22-alpine AS runner
+RUN apk add --no-cache espeak-ng
WORKDIR /app
ENV NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED=1
-RUN addgroup --system --gid 1001 nodejs
+RUN addgroup --system --gid 1001 nextjs
RUN adduser --system --uid 1001 nextjs
+RUN adduser nextjs nextjs
COPY --from=builder /app/public ./public
-COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
-COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
-COPY --from=builder --chown=nextjs:nodejs /app/prisma ./prisma
+COPY --from=builder --chown=nextjs:nextjs /app/.next/standalone ./
+COPY --from=builder --chown=nextjs:nextjs /app/.next/static ./.next/static
+COPY --from=builder --chown=nextjs:nextjs /app/prisma ./prisma
+COPY --from=builder --chown=nextjs:nextjs /app/node_modules/.prisma ./node_modules/.prisma
USER nextjs
diff --git a/app/api/curriculum/grade/route.ts b/app/api/curriculum/grade/route.ts
index ccee70a..1aa9dd1 100644
--- a/app/api/curriculum/grade/route.ts
+++ b/app/api/curriculum/grade/route.ts
@@ -1,5 +1,6 @@
import { NextRequest, NextResponse } from "next/server"
import { prisma } from "@/lib/db"
+import { buildFsrsCard, gradeCard, skillCodeFromGrade } from "@/lib/fsrs"
export async function POST(request: NextRequest) {
try {
@@ -9,13 +10,16 @@ export async function POST(request: NextRequest) {
return NextResponse.json({ error: "Missing required fields" }, { status: 400 })
}
- const sessionLog = await prisma.sessionLog.findFirst({
+ // Auto-create session log if none exists (so SkillAttempts are always stored)
+ let sessionLog = await prisma.sessionLog.findFirst({
where: { childId },
orderBy: { startedAt: "desc" },
})
if (!sessionLog) {
- return NextResponse.json({ error: "No active session" }, { status: 400 })
+ sessionLog = await prisma.sessionLog.create({
+ data: { childId, startedAt: new Date() },
+ })
}
await prisma.skillAttempt.create({
@@ -31,6 +35,47 @@ export async function POST(request: NextRequest) {
},
})
+ // Update/create FSRS card so due-date sorting works in the engine
+ const existingCard = await prisma.fsrsCard.findFirst({
+ where: { childId, skillCode },
+ })
+
+ const grade = skillCodeFromGrade(correct, responseMs ?? 5000)
+ const baseCard = buildFsrsCard({
+ stability: existingCard?.stability,
+ difficulty: existingCard?.difficulty,
+ reps: existingCard?.reps,
+ lapses: existingCard?.lapses,
+ due: existingCard?.dueAt,
+ })
+
+ const { card: updatedCard } = gradeCard(baseCard, grade)
+
+ if (existingCard) {
+ await prisma.fsrsCard.update({
+ where: { id: existingCard.id },
+ data: {
+ dueAt: updatedCard.due,
+ stability: updatedCard.stability,
+ difficulty: updatedCard.difficulty,
+ reps: updatedCard.reps,
+ lapses: updatedCard.lapses,
+ },
+ })
+ } else {
+ await prisma.fsrsCard.create({
+ data: {
+ childId,
+ skillCode,
+ dueAt: updatedCard.due,
+ stability: updatedCard.stability,
+ difficulty: updatedCard.difficulty,
+ reps: updatedCard.reps,
+ lapses: updatedCard.lapses,
+ },
+ })
+ }
+
return NextResponse.json({ success: true })
} catch (error) {
console.error("Curriculum grade error:", error)
diff --git a/app/api/curriculum/progress/route.ts b/app/api/curriculum/progress/route.ts
index 6da0953..247d88a 100644
--- a/app/api/curriculum/progress/route.ts
+++ b/app/api/curriculum/progress/route.ts
@@ -4,74 +4,79 @@ import { initCurriculum, getLecturaStages, getNumerosStages } from "@/curriculum
import { getCurrentStage, 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") as "lectura" | "numeros"
- if (!childId) {
- return NextResponse.json({ error: "childId required" }, { status: 400 })
- }
-
- await initCurriculum()
-
- const stages = topic === "lectura" ? getLecturaStages() : getNumerosStages()
- const stageKeys = Object.keys(stages).map(Number).sort((a, b) => a - b)
-
- const progress = prisma.curriculumProgress.findMany({
- where: { childId, topic },
- orderBy: { stage: "asc" },
- })
-
- const allAttempts = prisma.skillAttempt.findMany({
- where: {
- exerciseId: { not: null },
- stage: { not: null },
- sessionLog: { childId },
- },
- select: { exerciseId: true, correct: true },
- })
-
- const [progressRows, attempts] = await Promise.all([progress, allAttempts])
-
- const mastered = new Set(
- attempts.filter((a) => a.correct).map((a) => a.exerciseId),
- )
-
- const stagesInfo = stageKeys.map((sk) => {
- const s = stages[sk]
- const total = s?.exercises?.length || 0
- const completed = s?.exercises?.filter((e) => mastered.has(e.id)).length || 0
- const prog = progressRows.find((p) => p.stage === sk)
- return {
- stage: sk,
- name: s?.name || `Etapa ${sk}`,
- total,
- completed,
- unlocked: !!prog,
- completedAt: prog?.completedAt || null,
- percent: total > 0 ? Math.round((completed / total) * 100) : 0,
+ try {
+ const childId = request.nextUrl.searchParams.get("childId")
+ const topic = (request.nextUrl.searchParams.get("topic") || "lectura") as "lectura" | "numeros"
+ if (!childId) {
+ return NextResponse.json({ error: "childId required" }, { status: 400 })
}
- })
- const errorTypes = await prisma.skillAttempt.groupBy({
- by: ["errorType"],
- where: {
- errorType: { not: null },
- sessionLog: { childId },
- },
- _count: { errorType: true },
- })
+ await initCurriculum()
- const errorPatterns = errorTypes.map((e) => ({
- errorType: e.errorType,
- count: e._count.errorType,
- }))
+ const stages = topic === "lectura" ? getLecturaStages() : getNumerosStages()
+ const stageKeys = Object.keys(stages).map(Number).sort((a, b) => a - b)
- const activeProgress = progressRows.find((p) => !p.completedAt)
- const currentStage = activeProgress?.stage || 1
+ const progress = prisma.curriculumProgress.findMany({
+ where: { childId, topic },
+ orderBy: { stage: "asc" },
+ })
- return NextResponse.json({
- currentStage,
- stages: stagesInfo,
- errorPatterns,
- hasActiveSession: true,
- })
+ const allAttempts = prisma.skillAttempt.findMany({
+ where: {
+ exerciseId: { not: null },
+ stage: { not: null },
+ sessionLog: { childId },
+ },
+ select: { exerciseId: true, correct: true },
+ })
+
+ const [progressRows, attempts] = await Promise.all([progress, allAttempts])
+
+ const mastered = new Set(
+ attempts.filter((a) => a.correct).map((a) => a.exerciseId),
+ )
+
+ const stagesInfo = stageKeys.map((sk) => {
+ const s = stages[sk]
+ const total = s?.exercises?.length || 0
+ const completed = s?.exercises?.filter((e) => mastered.has(e.id)).length || 0
+ const prog = progressRows.find((p) => p.stage === sk)
+ return {
+ stage: sk,
+ name: s?.name || `Etapa ${sk}`,
+ total,
+ completed,
+ unlocked: !!prog,
+ completedAt: prog?.completedAt || null,
+ percent: total > 0 ? Math.round((completed / total) * 100) : 0,
+ }
+ })
+
+ const errorTypes = await prisma.skillAttempt.groupBy({
+ by: ["errorType"],
+ where: {
+ errorType: { not: null },
+ sessionLog: { childId },
+ },
+ _count: { errorType: true },
+ })
+
+ const errorPatterns = errorTypes.map((e) => ({
+ errorType: e.errorType,
+ count: e._count.errorType,
+ }))
+
+ const activeProgress = progressRows.find((p) => !p.completedAt)
+ const currentStage = activeProgress?.stage || 1
+
+ return NextResponse.json({
+ currentStage,
+ stages: stagesInfo,
+ errorPatterns,
+ hasActiveSession: true,
+ })
+ } catch (error) {
+ console.error("Progress route error:", error)
+ return NextResponse.json({ error: "Internal error" }, { status: 500 })
+ }
}
diff --git a/app/api/debug/reset/route.ts b/app/api/debug/reset/route.ts
new file mode 100644
index 0000000..a7a76e5
--- /dev/null
+++ b/app/api/debug/reset/route.ts
@@ -0,0 +1,25 @@
+import { NextRequest, NextResponse } from "next/server"
+import { prisma } from "@/lib/db"
+
+export async function POST(request: NextRequest) {
+ try {
+ const { childId } = await request.json()
+ if (!childId) {
+ return NextResponse.json({ error: "childId required" }, { status: 400 })
+ }
+
+ // Delete in dependency order
+ await prisma.skillAttempt.deleteMany({
+ where: { sessionLog: { childId } },
+ })
+ await prisma.sessionLog.deleteMany({ where: { childId } })
+ await prisma.fsrsCard.deleteMany({ where: { childId } })
+ await prisma.curriculumProgress.deleteMany({ where: { childId } })
+ await prisma.milestone.deleteMany({ where: { childId } })
+
+ return NextResponse.json({ success: true, message: `Reset state for child ${childId}` })
+ } catch (error) {
+ console.error("Debug reset error:", error)
+ return NextResponse.json({ error: "Internal error" }, { status: 500 })
+ }
+}
diff --git a/app/api/debug/seed/route.ts b/app/api/debug/seed/route.ts
new file mode 100644
index 0000000..e0875e3
--- /dev/null
+++ b/app/api/debug/seed/route.ts
@@ -0,0 +1,112 @@
+import { NextRequest, NextResponse } from "next/server"
+import { prisma } from "@/lib/db"
+import { getStagesForProfile } from "@/lib/curriculum/engine"
+
+export async function POST(request: NextRequest) {
+ try {
+ const { childId, profile = "ISABELLA", topic = "lectura", stages: stageIds, markAllCorrect = false } = await request.json()
+ if (!childId) {
+ return NextResponse.json({ error: "childId required" }, { status: 400 })
+ }
+
+ const child = await prisma.child.findUnique({
+ where: { id: childId },
+ select: { id: true, name: true, profile: true },
+ })
+
+ if (!child) {
+ return NextResponse.json({ error: "Child not found" }, { status: 404 })
+ }
+
+ const resolvedProfile = (profile || child.profile).toUpperCase()
+
+ // Get stages for this topic
+ const stages = await getStagesForProfile(resolvedProfile, topic)
+
+ // Filter to specific stages if requested
+ const stageEntries = stageIds
+ ? Object.entries(stages).filter(([id]) => stageIds.includes(Number(id)))
+ : Object.entries(stages)
+
+ let seeded = 0
+
+ for (const [stageId, stage] of stageEntries) {
+ // Ensure curriculum progress exists
+ const exists = await prisma.curriculumProgress.findUnique({
+ where: { childId_topic_stage: { childId, topic, stage: Number(stageId) } },
+ })
+
+ if (!exists) {
+ await prisma.curriculumProgress.create({
+ data: { childId, topic, stage: Number(stageId) },
+ })
+ }
+
+ // Create a session log
+ const sessionLog = await prisma.sessionLog.create({
+ data: { childId, startedAt: new Date() },
+ })
+
+ // Create skill attempts for each exercise in the stage
+ for (const ex of stage.exercises) {
+ await prisma.skillAttempt.create({
+ data: {
+ sessionLogId: sessionLog.id,
+ skillCode: ex.skillCode,
+ promptLevel: 0,
+ correct: markAllCorrect,
+ responseMs: 2000,
+ errorType: markAllCorrect ? null : (ex.errorType || "discriminacion-auditiva"),
+ exerciseId: ex.id,
+ stage: ex.stage,
+ },
+ })
+
+ // Create FSRS card
+ await prisma.fsrsCard.create({
+ data: {
+ childId,
+ skillCode: ex.skillCode,
+ dueAt: new Date(Date.now() + 86400000),
+ stability: markAllCorrect ? 3.0 : 0.5,
+ difficulty: markAllCorrect ? 4.0 : 7.0,
+ reps: 1,
+ lapses: markAllCorrect ? 0 : 1,
+ },
+ })
+
+ seeded++
+ }
+
+ // Mark stage as completed if markAllCorrect
+ if (markAllCorrect) {
+ await prisma.curriculumProgress.updateMany({
+ where: { childId, topic, stage: Number(stageId) },
+ data: { completedAt: new Date() },
+ })
+
+ // Advance to next stage
+ const nextStage = Number(stageId) + 1
+ const nextExists = await prisma.curriculumProgress.findUnique({
+ where: { childId_topic_stage: { childId, topic, stage: nextStage } },
+ })
+ if (!nextExists) {
+ await prisma.curriculumProgress.create({
+ data: { childId, topic, stage: nextStage },
+ })
+ }
+ }
+ }
+
+ return NextResponse.json({
+ success: true,
+ child: { id: child.id, name: child.name, profile: resolvedProfile },
+ topic,
+ stagesSeeded: stageEntries.length,
+ exercisesSeeded: seeded,
+ })
+ } catch (error) {
+ console.error("Debug seed error:", error)
+ return NextResponse.json({ error: "Internal error" }, { status: 500 })
+ }
+}
diff --git a/app/api/debug/state/route.ts b/app/api/debug/state/route.ts
new file mode 100644
index 0000000..495f73c
--- /dev/null
+++ b/app/api/debug/state/route.ts
@@ -0,0 +1,70 @@
+import { NextRequest, NextResponse } from "next/server"
+import { prisma } from "@/lib/db"
+import { getStagesForProfile } from "@/lib/curriculum/engine"
+
+export async function GET(request: NextRequest) {
+ try {
+ const childId = request.nextUrl.searchParams.get("childId")
+ const profile = request.nextUrl.searchParams.get("profile") || "ISABELLA"
+ 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: { id: true, name: true, profile: true },
+ })
+
+ const progress = await prisma.curriculumProgress.findMany({
+ where: { childId },
+ orderBy: [{ topic: "asc" }, { stage: "asc" }],
+ })
+
+ const skillAttempts = await prisma.skillAttempt.findMany({
+ where: { sessionLog: { childId } },
+ orderBy: { createdAt: "desc" },
+ take: 20,
+ })
+
+ const fsrsCards = await prisma.fsrsCard.findMany({
+ where: { childId },
+ orderBy: { dueAt: "asc" },
+ })
+
+ const sessionLogs = await prisma.sessionLog.findMany({
+ where: { childId },
+ orderBy: { startedAt: "desc" },
+ take: 5,
+ })
+
+ // Count mastered exercises for this topic
+ const mastered = new Set(
+ skillAttempts
+ .filter((a) => a.exerciseId && a.exerciseId.startsWith(`${topic[0]}${topic[1]}`) && a.correct)
+ .map((a) => a.exerciseId),
+ )
+
+ const stages = await getStagesForProfile(profile, topic)
+ const totalExercises = Object.values(stages).reduce((sum, s) => sum + s.exercises.length, 0)
+
+ return NextResponse.json({
+ child,
+ progress,
+ skillAttempts: skillAttempts.slice(0, 10),
+ fsrsCards: fsrsCards.slice(0, 10),
+ sessionLogs,
+ stats: {
+ totalExercisesInTopic: totalExercises,
+ masteredInTopic: mastered.size,
+ progressPercent: totalExercises > 0 ? Math.round((mastered.size / totalExercises) * 100) : 0,
+ currentStage: progress.find((p) => !p.completedAt)?.stage || null,
+ currentTopic: progress.find((p) => !p.completedAt)?.topic || null,
+ },
+ })
+ } catch (error) {
+ console.error("Debug state error:", error)
+ return NextResponse.json({ error: "Internal error" }, { status: 500 })
+ }
+}
diff --git a/app/api/tts/route.ts b/app/api/tts/route.ts
index a115528..aa890e4 100644
--- a/app/api/tts/route.ts
+++ b/app/api/tts/route.ts
@@ -1,5 +1,5 @@
import { NextRequest, NextResponse } from "next/server"
-import { execSync } from "child_process"
+import { execFileSync } from "child_process"
import { existsSync, mkdirSync } from "fs"
import { createHash } from "crypto"
import { readFile, mkdir } from "fs/promises"
@@ -24,15 +24,23 @@ export async function GET(request: NextRequest) {
return NextResponse.json({ error: "text param required (max 200 chars)" }, { status: 400 })
}
+ // Sanitize: only allow alphanumeric, spaces, and common punctuation
+ if (!/^[\w\sáéíóúñüÁÉÍÓÚÑÜ.,;:!¿?¡()\-]+$/.test(text)) {
+ return NextResponse.json({ error: "Invalid text characters" }, { status: 400 })
+ }
+
await ensureCacheDir()
const cachePath = getCachePath(text)
if (!existsSync(cachePath)) {
try {
- execSync(
- `espeak-ng -v es-mx -s 140 -p 60 "${text.replace(/"/g, '\\"')}" -w "${cachePath}"`,
- { timeout: 10000 },
- )
+ execFileSync("espeak-ng", [
+ "-v", "es-mx",
+ "-s", "140",
+ "-p", "60",
+ "-w", cachePath,
+ text,
+ ], { timeout: 10000 })
} catch {
return NextResponse.json({ error: "TTS generation failed" }, { status: 500 })
}
diff --git a/app/francesca/ortografia/page.tsx b/app/francesca/ortografia/page.tsx
new file mode 100644
index 0000000..728938a
--- /dev/null
+++ b/app/francesca/ortografia/page.tsx
@@ -0,0 +1,12 @@
+import ExerciseSession from "@/components/child/exercise-session"
+
+export default function FrancescaOrtografia() {
+ return (
+
+ )
+}
diff --git a/app/francesca/page.tsx b/app/francesca/page.tsx
index 280ae00..68a0e82 100644
--- a/app/francesca/page.tsx
+++ b/app/francesca/page.tsx
@@ -8,8 +8,9 @@ import AudioUnlock from "@/components/child/audio-unlock"
const FRANCESCA_KEY = "edueasy_child_francesca"
const activities = [
- { id: "matematicas", label: "Matemáticas", icon: "🔢", description: "Sumas, restas y multiplicaciones" },
- { id: "lecturas", label: "Lecturas cortas", icon: "📖", description: "Leé textos chiquitos" },
+ { id: "matematicas", label: "Matemáticas", icon: "🔢", description: "Sumas, restas, tablas y divisiones" },
+ { id: "lecturas", label: "Lecturas cortas", icon: "📖", description: "Leé textos sobre distintos temas" },
+ { id: "ortografia", label: "Ortografía", icon: "✏️", description: "Mayúsculas y reglas" },
{ id: "logros", label: "Mis logros", icon: "⭐", color: "bg-warning" },
{ id: "pairing", label: "iPad nuevo", icon: "🔗", color: "bg-secondary" },
]
@@ -23,6 +24,16 @@ export default function FrancescaHome() {
setPaired(!!localStorage.getItem(FRANCESCA_KEY))
}, [])
+ const handleActivity = (id: string) => {
+ if (id === "logros") {
+ router.push("/child/logros?profile=francesca")
+ } else if (id === "pairing") {
+ router.push("/child/pairing?profile=francesca")
+ } else {
+ router.push(`/francesca/${id}`)
+ }
+ }
+
if (!audioReady) {
return setAudioReady(true)} />
}
@@ -46,7 +57,7 @@ export default function FrancescaHome() {
{activities.map((a) => (