feat: Puente Mayúscula-Minúscula + TTS SpeechSynthesis + E2E fixes

This commit is contained in:
renato97
2026-07-21 15:31:46 -03:00
commit fa8e0c58ce
120 changed files with 16013 additions and 0 deletions
+4
View File
@@ -0,0 +1,4 @@
import { auth } from "@/lib/auth"
import { toNextJsHandler } from "better-auth/next-js"
export const { POST, GET } = toNextJsHandler(auth)
+57
View File
@@ -0,0 +1,57 @@
import { NextRequest, NextResponse } from "next/server"
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 })
}
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 })
}
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 })
}
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 })
}
+39
View File
@@ -0,0 +1,39 @@
import { NextRequest, NextResponse } from "next/server"
import { prisma } from "@/lib/db"
export async function POST(request: NextRequest) {
try {
const { childId, exerciseId, skillCode, correct, promptLevel, responseMs, errorType, stage } = await request.json()
if (!childId || !exerciseId || !skillCode) {
return NextResponse.json({ error: "Missing required fields" }, { status: 400 })
}
const sessionLog = await prisma.sessionLog.findFirst({
where: { childId },
orderBy: { startedAt: "desc" },
})
if (!sessionLog) {
return NextResponse.json({ error: "No active session" }, { status: 400 })
}
await prisma.skillAttempt.create({
data: {
sessionLogId: sessionLog.id,
skillCode,
promptLevel: promptLevel ?? 0,
correct,
responseMs: responseMs ?? null,
errorType: errorType ?? null,
exerciseId,
stage: stage ?? null,
},
})
return NextResponse.json({ success: true })
} catch (error) {
console.error("Curriculum grade error:", error)
return NextResponse.json({ error: "Internal error" }, { status: 500 })
}
}
+13
View File
@@ -0,0 +1,13 @@
import { NextRequest, NextResponse } from "next/server"
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") as "lectura" | "numeros"
if (!childId) {
return NextResponse.json({ error: "childId required" }, { status: 400 })
}
const exercise = await getNextExercise(childId, topic)
return NextResponse.json(exercise || { id: null, done: true })
}
+77
View File
@@ -0,0 +1,77 @@
import { NextRequest, NextResponse } from "next/server"
import { prisma } from "@/lib/db"
import { initCurriculum, getLecturaStages, getNumerosStages } from "@/curriculum/index"
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,
}
})
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,
})
}
+159
View File
@@ -0,0 +1,159 @@
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 })
}
const today = new Date()
today.setHours(0, 0, 0, 0)
const [totalSessions, todaySessions, milestones, fsrsCards, lastSession, weeklySessions, milestoneRecords, curriculumProgress, errorPatterns, allAttempts] =
await Promise.all([
prisma.sessionLog.count({ where: { childId } }),
prisma.sessionLog.count({
where: { childId, startedAt: { gte: today } },
}),
prisma.milestone.count({ where: { childId } }),
prisma.fsrsCard.findMany({ where: { childId } }),
prisma.sessionLog.findFirst({
where: { childId },
orderBy: { startedAt: "desc" },
include: { skillAttempts: true },
}),
prisma.sessionLog.findMany({
where: { childId, startedAt: { gte: new Date(Date.now() - 7 * 86400000) } },
orderBy: { startedAt: "asc" },
select: { startedAt: true, durationSec: true },
}),
prisma.milestone.findMany({
where: { childId },
orderBy: { reachedAt: "desc" },
select: { skillCode: true, reachedAt: true },
}),
prisma.curriculumProgress.findMany({
where: { childId },
orderBy: { stage: "asc" },
}),
prisma.skillAttempt.findMany({
where: {
errorType: { not: null },
sessionLog: { childId },
},
select: { errorType: true },
}),
prisma.skillAttempt.findMany({
where: {
correct: false,
exerciseId: { not: null },
sessionLog: { childId },
},
select: { exerciseId: true, errorType: true },
}),
])
const todayMinutes = todaySessions > 0
? await prisma.sessionLog.aggregate({
where: { childId, startedAt: { gte: today } },
_sum: { durationSec: true },
}).then((r) => Math.round((r._sum.durationSec ?? 0) / 60))
: 0
const totalSkills = fsrsCards.length
const masteredSkills = fsrsCards.filter((c) => c.reps >= 5 && c.stability > 30).length
const masteryPercent = totalSkills > 0 ? Math.round((masteredSkills / totalSkills) * 100) : 0
const weeklyData = weeklySessions.map((s) => ({
date: s.startedAt.toISOString().slice(0, 10),
minutes: Math.round((s.durationSec ?? 0) / 60),
}))
const skillBreakdown = fsrsCards.map((c) => ({
skillCode: c.skillCode,
stability: c.stability,
difficulty: c.difficulty,
reps: c.reps,
due: c.dueAt.toISOString(),
}))
const streak = await calculateStreak(childId)
const totalErrors = allAttempts.length
const errorTypeCount = errorPatterns.reduce<Record<string, number>>((acc, e) => {
const key = e.errorType || "sin-clasificar"
acc[key] = (acc[key] || 0) + 1
return acc
}, {})
const stageProgress = curriculumProgress.map((cp) => ({
topic: cp.topic,
stage: cp.stage,
completedAt: cp.completedAt?.toISOString() || null,
}))
return NextResponse.json({
totalSessions,
todaySessions,
todayMinutes,
masteryPercent,
masteredSkills,
totalSkills,
milestones,
streak,
weeklyData,
skillBreakdown,
milestoneData: milestoneRecords,
stageProgress,
errorTypeCount,
totalErrors,
lastSession: lastSession
? {
startedAt: lastSession.startedAt.toISOString(),
durationSec: lastSession.durationSec,
skillsPracticed: lastSession.skillsPracticed,
correctCount: lastSession.skillAttempts.filter((a) => a.correct).length,
totalAttempts: lastSession.skillAttempts.length,
}
: null,
})
}
async function calculateStreak(childId: string): Promise<number> {
const logs = await prisma.sessionLog.findMany({
where: { childId },
orderBy: { startedAt: "desc" },
select: { startedAt: true },
})
if (logs.length === 0) return 0
const days = [
...new Set(logs.map((l) => l.startedAt.toISOString().slice(0, 10))),
].sort()
.reverse()
let streak = 1
const today = new Date().toISOString().slice(0, 10)
if (days[0] !== today && days[0] !== getYesterday()) return 0
for (let i = 1; i < days.length; i++) {
const prev = new Date(days[i - 1])
const curr = new Date(days[i])
const diff = (prev.getTime() - curr.getTime()) / 86400000
if (Math.round(diff) === 1) {
streak++
} else {
break
}
}
return streak
}
function getYesterday(): string {
const d = new Date()
d.setDate(d.getDate() - 1)
return d.toISOString().slice(0, 10)
}
+57
View File
@@ -0,0 +1,57 @@
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 {
const { childId, skillCode, correct, responseMs } = await request.json()
if (!childId || !skillCode) {
return NextResponse.json({ error: "childId and skillCode required" }, { status: 400 })
}
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("FSRS grade error:", error)
return NextResponse.json({ error: "Internal error" }, { status: 500 })
}
}
+35
View File
@@ -0,0 +1,35 @@
import { NextRequest, NextResponse } from "next/server"
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 })
}
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,
})
return NextResponse.json({
...dueCard,
cardId: dueCard.id,
retrievability: getRetrievability(card),
})
}
return NextResponse.json({ skillCode: null })
}
+36
View File
@@ -0,0 +1,36 @@
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 })
}
const milestones = await prisma.milestone.findMany({
where: { childId },
orderBy: { reachedAt: "desc" },
})
return NextResponse.json(milestones)
}
export async function POST(request: Request) {
try {
const { childId, skillCode } = await request.json()
if (!childId || !skillCode) {
return NextResponse.json({ error: "childId and skillCode required" }, { status: 400 })
}
const existing = await prisma.milestone.findUnique({
where: { childId_skillCode: { childId, skillCode } },
})
if (existing) {
return NextResponse.json(existing)
}
const milestone = await prisma.milestone.create({
data: { childId, skillCode },
})
return NextResponse.json(milestone, { status: 201 })
} catch (error) {
console.error("Milestone error:", error)
return NextResponse.json({ error: "Internal error" }, { status: 500 })
}
}
+98
View File
@@ -0,0 +1,98 @@
import { NextResponse } from "next/server"
import { prisma } from "@/lib/db"
import { randomUUID } from "crypto"
export async function POST(request: Request) {
try {
const { deviceFingerprint, pairingCode, childId, action } = await request.json()
if (action === "generate") {
if (!childId) {
return NextResponse.json({ error: "childId required" }, { status: 400 })
}
const code = randomUUID().slice(0, 8).toUpperCase()
await prisma.pendingPairing.create({
data: {
code,
childId,
expiresAt: new Date(Date.now() + 10 * 60 * 1000),
},
})
return NextResponse.json({ pairingCode: code })
}
if (pairingCode) {
const pending = await prisma.pendingPairing.findUnique({
where: { code: pairingCode },
})
if (!pending || pending.expiresAt < new Date()) {
return NextResponse.json({ error: "Código inválido o expirado" }, { status: 404 })
}
const targetChildId = childId || pending.childId
if (!targetChildId) {
return NextResponse.json({ error: "Sin niño asociado" }, { status: 400 })
}
if (childId && !pending.childId) {
await prisma.pendingPairing.update({
where: { id: pending.id },
data: { childId },
})
}
const fp = deviceFingerprint || pending.deviceFingerprint
if (!fp) {
return NextResponse.json({ error: "Se requiere deviceFingerprint" }, { status: 400 })
}
const device = await prisma.device.create({
data: {
childId: targetChildId,
deviceFingerprint: fp,
role: "child",
},
include: { child: true },
})
await prisma.pendingPairing.delete({ where: { id: pending.id } })
return NextResponse.json({
success: true,
childId: device.childId,
childName: device.child.name,
})
}
if (deviceFingerprint) {
const existingDevice = await prisma.device.findFirst({
where: { deviceFingerprint },
})
if (existingDevice) {
return NextResponse.json({
paired: true,
childId: existingDevice.childId,
role: existingDevice.role,
})
}
const code = randomUUID().slice(0, 8).toUpperCase()
await prisma.pendingPairing.create({
data: {
code,
deviceFingerprint,
expiresAt: new Date(Date.now() + 10 * 60 * 1000),
},
})
return NextResponse.json({
paired: false,
pairingCode: code,
})
}
return NextResponse.json({ error: "Invalid request" }, { status: 400 })
} catch (error) {
console.error("Pairing error:", error)
return NextResponse.json({ error: "Internal error" }, { status: 500 })
}
}
+36
View File
@@ -0,0 +1,36 @@
import { NextRequest, NextResponse } from "next/server"
import { prisma } from "@/lib/db"
export async function POST(request: NextRequest) {
try {
const body = await request.json()
const { childId, session, attempts } = body
if (!childId || !session) {
return NextResponse.json({ error: "Missing required fields" }, { status: 400 })
}
const sessionLog = await prisma.sessionLog.create({
data: {
childId,
startedAt: new Date(session.startedAt),
endedAt: session.endedAt ? new Date(session.endedAt) : undefined,
durationSec: session.durationSec,
skillsPracticed: session.skillsPracticed,
skillAttempts: {
create: (attempts || []).map((a: any) => ({
skillCode: a.skillCode,
promptLevel: a.promptLevel ?? 0,
correct: a.correct,
responseMs: a.responseMs,
})),
},
},
})
return NextResponse.json({ success: true, sessionId: sessionLog.id })
} catch (error) {
console.error("Sync error:", error)
return NextResponse.json({ error: "Internal error" }, { status: 500 })
}
}
+49
View File
@@ -0,0 +1,49 @@
import { NextRequest, NextResponse } from "next/server"
import { execSync } from "child_process"
import { existsSync, mkdirSync } from "fs"
import { createHash } from "crypto"
import { readFile, mkdir } from "fs/promises"
import { join } from "path"
const CACHE_DIR = join(process.cwd(), "public", "tts-cache")
async function ensureCacheDir() {
if (!existsSync(CACHE_DIR)) {
await mkdir(CACHE_DIR, { recursive: true })
}
}
function getCachePath(text: string): string {
const hash = createHash("md5").update(text).digest("hex")
return join(CACHE_DIR, `${hash}.wav`)
}
export async function GET(request: NextRequest) {
const text = request.nextUrl.searchParams.get("text")
if (!text || text.length > 200) {
return NextResponse.json({ error: "text param required (max 200 chars)" }, { 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 },
)
} catch {
return NextResponse.json({ error: "TTS generation failed" }, { status: 500 })
}
}
const audioBuffer = await readFile(cachePath)
return new NextResponse(audioBuffer, {
headers: {
"Content-Type": "audio/wav",
"Content-Length": audioBuffer.length.toString(),
"Cache-Control": "public, max-age=31536000, immutable",
},
})
}