diff --git a/docs/product-analytics.md b/docs/product-analytics.md index c646fe2..0ba5c05 100644 --- a/docs/product-analytics.md +++ b/docs/product-analytics.md @@ -340,10 +340,24 @@ Borrar la cuenta borra sus eventos, sesiones y pistas por `ON DELETE CASCADE`. | | `BugReport` | `Feedback` | |---|---|---| -| Para qué | Contenido roto: typo, test mal configurado | Experiencia: "me confundió", idea, algo que gustó | +| Para qué | Contenido roto: typo, test mal configurado | Experiencia: discrepancia con la clase, "me confundió", idea, algo que gustó | | Target | Paso, reto o ejercicio de práctica (elegido por el alumno) | Ruta, superficie y recurso **derivados por el servidor** | | Estados | `open` → `triaged` → `resolved` / `duplicate` / `wontfix` | los mismos (`ReportStatus`) | +`Feedback.kind` (`FeedbackKind`): `discrepancy` (el contenido no corresponde +con lo que el alumno vio en su clase, o una lección/unidad le pareció mala), +`confusing`, `idea`, `praise`, `other`. El botón dedicado de discrepancia +vive junto al de `BugReport` en la cabecera de la lección, y en la página de +unidad — el alumno lo ve donde nota el problema, sin buscar un menú aparte. + +`Feedback.surface` incluye `unit` cuando el reporte es sobre una unidad +completa (`unitId` set, `lessonId` null) en vez de una lección puntual; +ambos IDs se derivan de la ruta contra la BD, nunca del cliente. + +Ninguno de los dos crea un issue de GitHub — la mayoría de los alumnos no +sabe qué es GitHub y no lo usaría. Todo cae en la cola de triage interna; un +admin decide si vale la pena normalizarlo como issue real (`issueUrl`). + Ambos guardan evidencia del cierre: `resolutionNote`, `issueUrl`, `prUrl`, `triagedAt`, `resolvedAt`, `handledById`. diff --git a/prisma/migrations/20260905000000_add_discrepancy_reports/migration.sql b/prisma/migrations/20260905000000_add_discrepancy_reports/migration.sql new file mode 100644 index 0000000..09214fd --- /dev/null +++ b/prisma/migrations/20260905000000_add_discrepancy_reports/migration.sql @@ -0,0 +1,14 @@ +-- Reportar discrepancias con la clase real (o "mala sesión/unidad") desde +-- el mismo widget de Feedback, sin pasar por GitHub. +-- +-- ALTER TYPE ... ADD VALUE es seguro (idempotente con IF NOT EXISTS) y +-- ningún valor nuevo se usa en esta misma migración. +ALTER TYPE "FeedbackKind" ADD VALUE IF NOT EXISTS 'discrepancy'; +ALTER TYPE "ProductSurface" ADD VALUE IF NOT EXISTS 'unit'; + +-- Reporte a nivel de unidad completa (no de una lección puntual). +ALTER TABLE "feedback" ADD COLUMN "unitId" TEXT; + +ALTER TABLE "feedback" + ADD CONSTRAINT "feedback_unitId_fkey" + FOREIGN KEY ("unitId") REFERENCES "unit"("id") ON DELETE SET NULL ON UPDATE CASCADE; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 6a25abc..ad13f3c 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -249,6 +249,7 @@ model Unit { lessons Lesson[] practiceExercises PracticeExercise[] socialEvents SocialEvent[] + feedback Feedback[] @@unique([courseId, slug]) @@index([courseId, order]) @@ -809,6 +810,14 @@ model Feedback { */ path String? surface ProductSurface? + /** + * Presente cuando el reporte es sobre una unidad completa ("mala sesión", + * contenido que no corresponde con la clase) y no sobre una lección + * puntual. Mutuamente exclusivo con `lessonId` en la práctica, aunque no + * hay CHECK constraint: el servidor decide cuál llenar según la ruta. + */ + unitId String? + unit Unit? @relation(fields: [unitId], references: [id], onDelete: SetNull) lessonId String? lesson Lesson? @relation(fields: [lessonId], references: [id], onDelete: SetNull) practiceExerciseId String? @@ -830,6 +839,11 @@ model Feedback { } enum FeedbackKind { + /** + * "esto no corresponde con lo que vi en mi clase" — discrepancia entre el + * contenido y la clase real, o "esta fue una mala unidad/lección". + */ + discrepancy /** * "no entendí esto" */ @@ -961,6 +975,10 @@ enum ProductEventName { enum ProductSurface { lesson + /** + * Reporte sobre una unidad completa, no sobre una lección puntual. + */ + unit practice playground app diff --git a/src/app/app/c/[courseSlug]/u/[unitSlug]/page.tsx b/src/app/app/c/[courseSlug]/u/[unitSlug]/page.tsx index 51509c1..25041ff 100644 --- a/src/app/app/c/[courseSlug]/u/[unitSlug]/page.tsx +++ b/src/app/app/c/[courseSlug]/u/[unitSlug]/page.tsx @@ -4,6 +4,7 @@ import { ArrowRight, Check } from "lucide-react"; import { BrickRow } from "@/components/ui/bricks"; import { Button } from "@/components/ui/button"; +import { ReportDiscrepancyButton } from "@/features/feedback/components/report-discrepancy-button"; import { RoadmapLessons } from "@/features/roadmap/components/roadmap-lessons"; import { getCourseBySlug } from "@/features/roadmap/queries"; import { getUnitBySlug } from "@/features/lessons/queries"; @@ -51,7 +52,7 @@ export default async function UnitPage({ params }: PageProps) { > {unitComplete ? : unit.order} -
+

Unidad {unit.order}

@@ -64,6 +65,7 @@ export default async function UnitPage({ params }: PageProps) {

)}
+

diff --git a/src/features/admin/components/triage-row.tsx b/src/features/admin/components/triage-row.tsx index a8ddf69..0440a80 100644 --- a/src/features/admin/components/triage-row.tsx +++ b/src/features/admin/components/triage-row.tsx @@ -23,6 +23,14 @@ const STATUSES: { value: ReportStatusInput; label: string }[] = [ { value: "wontfix", label: "No se arregla" }, ]; +const FEEDBACK_KIND_LABEL: Record = { + discrepancy: "Discrepancia con clase", + confusing: "Confuso", + idea: "Idea", + praise: "Elogio", + other: "Otro", +}; + const STATUS_VARIANT: Record = { open: "warning", triaged: "default", @@ -69,8 +77,19 @@ export function TriageRow({ item }: { item: TriageItem }) { return (
- - {item.kind === "bug" ? "Bug de contenido" : `Feedback · ${item.feedbackKind}`} + + {item.kind === "bug" + ? "Bug de contenido" + : `Feedback · ${ + FEEDBACK_KIND_LABEL[item.feedbackKind ?? ""] ?? item.feedbackKind + }`} {item.status} diff --git a/src/features/admin/queries.ts b/src/features/admin/queries.ts index 53fbf2b..81e21e3 100644 --- a/src/features/admin/queries.ts +++ b/src/features/admin/queries.ts @@ -111,6 +111,13 @@ export async function getTriageQueue(options: { issueUrl: true, prUrl: true, user: { select: { username: true } }, + unit: { + select: { + title: true, + slug: true, + course: { select: { slug: true } }, + }, + }, lesson: { select: { title: true, @@ -162,14 +169,18 @@ export async function getTriageQueue(options: { const feedbackItems: TriageItem[] = feedback.map((item) => { const target = item.lesson ? `Lección: ${item.lesson.title}` - : item.practiceExercise - ? `Práctica: ${item.practiceExercise.title}` - : (item.path ?? "Sin contexto"); + : item.unit + ? `Unidad: ${item.unit.title}` + : item.practiceExercise + ? `Práctica: ${item.practiceExercise.title}` + : (item.path ?? "Sin contexto"); const href = item.lesson ? `/app/c/${item.lesson.unit.course.slug}/u/${item.lesson.unit.slug}/${item.lesson.slug}` - : item.practiceExercise - ? `/app/c/${item.practiceExercise.course.slug}/ejercicios/${item.practiceExercise.slug}` - : item.path; + : item.unit + ? `/app/c/${item.unit.course.slug}/u/${item.unit.slug}` + : item.practiceExercise + ? `/app/c/${item.practiceExercise.course.slug}/ejercicios/${item.practiceExercise.slug}` + : item.path; return { id: item.id, kind: "feedback", diff --git a/src/features/feedback/actions.ts b/src/features/feedback/actions.ts index c50c60f..9df2633 100644 --- a/src/features/feedback/actions.ts +++ b/src/features/feedback/actions.ts @@ -11,18 +11,22 @@ import { parseOrThrow } from "@/lib/validation"; import { FEEDBACK_MAX_LENGTH, resolveFeedbackContext } from "./context"; /** - * Feedback general: "esto me confundió", "estaría bueno que...", "me gustó". + * Feedback general: "esto no corresponde con mi clase", "esto me confundió", + * "estaría bueno que...", "me gustó". * * NO reemplaza a `BugReport`, que sigue siendo para contenido roto y apunta a - * un paso/ejercicio concreto. Aquí el contexto (ruta, superficie, recurso) lo - * DERIVA EL SERVIDOR de la ruta: al alumno no se le pide que explique dónde - * estaba, y tampoco se le cree ciegamente lo que manda. + * un paso/ejercicio concreto. Aquí el contexto (ruta, superficie, recurso — + * incluida la unidad completa cuando el reporte es sobre una) lo DERIVA EL + * SERVIDOR de la ruta: al alumno no se le pide que explique dónde estaba, y + * tampoco se le cree ciegamente lo que manda. * * Lo único que se guarda del usuario es su mensaje y su id. Nada de user - * agent, IP, resolución de pantalla ni contenido del editor. + * agent, IP, resolución de pantalla ni contenido del editor. Nada de esto + * crea un issue de GitHub: cae en la cola de triage interna + * (`/app/admin/reportes`) para que un admin lo normalice si vale la pena. */ const feedbackSchema = z.object({ - kind: z.enum(["confusing", "idea", "praise", "other"]), + kind: z.enum(["discrepancy", "confusing", "idea", "praise", "other"]), message: z .string() .trim() @@ -38,7 +42,7 @@ const feedbackSchema = z.object({ export const submitFeedback = withActionErrorHandling( "submitFeedback", async (input: { - kind: "confusing" | "idea" | "praise" | "other"; + kind: "discrepancy" | "confusing" | "idea" | "praise" | "other"; message: string; path?: string; }): Promise<{ ok: true }> => { @@ -56,6 +60,7 @@ export const submitFeedback = withActionErrorHandling( message, path: context.path, surface: context.surface, + unitId: context.unitId, lessonId: context.lessonId, practiceExerciseId: context.practiceExerciseId, }, diff --git a/src/features/feedback/components/feedback-dialog.tsx b/src/features/feedback/components/feedback-dialog.tsx index 7182a0e..6b34416 100644 --- a/src/features/feedback/components/feedback-dialog.tsx +++ b/src/features/feedback/components/feedback-dialog.tsx @@ -2,7 +2,14 @@ import * as React from "react"; import { usePathname } from "next/navigation"; -import { Lightbulb, MessageSquarePlus, Send, Sparkles, HelpCircle } from "lucide-react"; +import { + AlertTriangle, + Lightbulb, + MessageSquarePlus, + Send, + Sparkles, + HelpCircle, +} from "lucide-react"; import { toast } from "sonner"; import { Button } from "@/components/ui/button"; @@ -20,31 +27,73 @@ import { submitFeedback } from "@/features/feedback/actions"; import { FEEDBACK_MAX_LENGTH } from "@/features/feedback/context"; import { cn } from "@/lib/utils"; -type Kind = "confusing" | "idea" | "praise"; +type Kind = "discrepancy" | "confusing" | "idea" | "praise"; const KINDS: { value: Kind; label: string; icon: typeof HelpCircle }[] = [ + { + value: "discrepancy", + label: "No corresponde con mi clase", + icon: AlertTriangle, + }, { value: "confusing", label: "Algo me confundió", icon: HelpCircle }, { value: "idea", label: "Tengo una idea", icon: Lightbulb }, { value: "praise", label: "Algo me gustó", icon: Sparkles }, ]; +const COPY: Record< + Kind, + { title: string; description: string; placeholder: string } +> = { + discrepancy: { + title: "Reportar una discrepancia", + description: + '¿Esta lección o unidad no corresponde con lo que viste en tu clase, o te pareció una mala sesión? Cuéntanos qué no cuadra — ya sabemos en qué pantalla estás, no hace falta que lo expliques.', + placeholder: + 'Ej: "En mi clase vimos primero los arreglos y aquí aparecen hasta después de POO."', + }, + confusing: { + title: "¿Cómo te está yendo?", + description: + "Esto nos ayuda a decidir qué mejorar. Sabemos en qué pantalla estás, no hace falta que lo expliques.", + placeholder: 'Ej: "No entendí para qué sirve el for anidado en esta lección."', + }, + idea: { + title: "¿Cómo te está yendo?", + description: + "Esto nos ayuda a decidir qué mejorar. Sabemos en qué pantalla estás, no hace falta que lo expliques.", + placeholder: 'Ej: "Estaría bueno tener modo oscuro en el editor."', + }, + praise: { + title: "¿Cómo te está yendo?", + description: + "Esto nos ayuda a decidir qué mejorar. Sabemos en qué pantalla estás, no hace falta que lo expliques.", + placeholder: 'Ej: "Me encantó cómo explicaron los punteros."', + }, +}; + /** - * Feedback general sobre la experiencia. El contexto (dónde estaba el - * alumno) viaja solo: mandamos la ruta y el servidor la interpreta. No le - * pedimos que explique en qué pantalla estaba. + * Feedback general sobre la experiencia — incluye reportar que el contenido + * NO corresponde con la clase real, o que una lección/unidad fue mala. El + * contexto (dónde estaba el alumno) viaja solo: mandamos la ruta y el + * servidor la interpreta. No le pedimos que explique en qué pantalla estaba. * * Para contenido roto (typo, test mal configurado) existe `ReportBugDialog`, - * que sí apunta a un paso/ejercicio concreto. + * que sí apunta a un paso/ejercicio concreto. Ninguno de los dos crea un + * issue de GitHub: caen en la cola de triage interna (`/app/admin/reportes`) + * y un admin decide si vale la pena abrir uno. */ export function FeedbackDialog({ children, open: controlledOpen, onOpenChange, + defaultKind = "confusing", }: { children?: React.ReactNode; /** Modo controlado (para abrirlo desde un menú, que se cierra al elegir). */ open?: boolean; onOpenChange?: (open: boolean) => void; + /** Qué opción viene preseleccionada al abrir (p. ej. "discrepancy" desde un botón dedicado). */ + defaultKind?: Kind; }) { const [uncontrolledOpen, setUncontrolledOpen] = React.useState(false); const isControlled = controlledOpen !== undefined; @@ -56,11 +105,25 @@ export function FeedbackDialog({ }, [isControlled, onOpenChange], ); - const [kind, setKind] = React.useState("confusing"); + const [kind, setKind] = React.useState(defaultKind); const [message, setMessage] = React.useState(""); const [submitting, setSubmitting] = React.useState(false); const pathname = usePathname(); + // Reabrir con la opción correcta preseleccionada: el diálogo del rail y el + // de "reportar discrepancia" comparten componente pero no instancia de + // estado si el caller lo re-monta, así que al reabrir alineamos con lo que + // pidió el trigger en vez de arrastrar la última selección. Ajuste en + // render (no en un efecto) siguiendo el patrón de React para "resetear + // estado cuando cambia un prop". + const [wasOpen, setWasOpen] = React.useState(open); + if (open !== wasOpen) { + setWasOpen(open); + if (open) setKind(defaultKind); + } + + const copy = COPY[kind]; + const trimmed = message.trim(); const tooShort = trimmed.length > 0 && trimmed.length < 5; const canSubmit = @@ -99,11 +162,8 @@ export function FeedbackDialog({
- ¿Cómo te está yendo? - - Esto nos ayuda a decidir qué mejorar. Sabemos en qué pantalla - estás, no hace falta que lo expliques. - + {copy.title} + {copy.description}
setMessage(e.target.value)} - placeholder='Ej: "No entendí para qué sirve el for anidado en esta lección."' + placeholder={copy.placeholder} rows={4} maxLength={FEEDBACK_MAX_LENGTH} aria-invalid={tooShort || undefined} diff --git a/src/features/feedback/components/report-discrepancy-button.tsx b/src/features/feedback/components/report-discrepancy-button.tsx new file mode 100644 index 0000000..8d7e0d2 --- /dev/null +++ b/src/features/feedback/components/report-discrepancy-button.tsx @@ -0,0 +1,32 @@ +"use client"; + +import { AlertTriangle } from "lucide-react"; + +import { Button } from "@/components/ui/button"; +import { FeedbackDialog } from "@/features/feedback/components/feedback-dialog"; + +/** + * Botón dedicado para reportar que el contenido NO corresponde con la clase + * real, o que una lección/unidad fue mala — sin pasar por GitHub. Reusa el + * mismo modelo (`Feedback`) y la misma cola de triage que "Enviar + * comentario"; sólo cambia la opción que viene preseleccionada al abrir. + * + * Vive junto al `ReportBugDialog` en la cabecera de la lección y en la + * página de unidad: el alumno lo ve exactamente donde nota el problema, sin + * tener que buscarlo en un menú aparte. + */ +export function ReportDiscrepancyButton() { + return ( + + + + ); +} diff --git a/src/features/feedback/context.ts b/src/features/feedback/context.ts index 7fe8b04..e7284ad 100644 --- a/src/features/feedback/context.ts +++ b/src/features/feedback/context.ts @@ -8,6 +8,7 @@ export const FEEDBACK_MAX_LENGTH = 2_000; export interface FeedbackContext { path: string | null; surface: ProductSurface | null; + unitId: string | null; lessonId: string | null; practiceExerciseId: string | null; } @@ -32,8 +33,10 @@ export function sanitizePath(raw: string | undefined | null): string | null { * * Rutas reconocidas (canónicas y legacy): * · `/app/c//u//` → lección + * · `/app/c//u/` → unidad completa * · `/app/c//ejercicios/` → ejercicio de práctica * · `/app/u//` → lección del curso C++ legacy + * · `/app/u/` → unidad del curso C++ legacy * · `/app/ejercicios/` → práctica del curso C++ legacy * · cualquier otra de la app → superficie `app` * @@ -49,6 +52,7 @@ export async function resolveFeedbackContext( const empty: FeedbackContext = { path, surface: null, + unitId: null, lessonId: null, practiceExerciseId: null, }; @@ -78,11 +82,27 @@ export async function resolveFeedbackContext( return { path, surface: "lesson", + unitId: null, lessonId: lesson?.id ?? null, practiceExerciseId: null, }; } + // .../u/ (la unidad completa, sin lección puntual) + if (rest[0] === "u" && rest.length === 2) { + const unit = await db.unit.findFirst({ + where: { slug: rest[1], course: { slug: courseSlug } }, + select: { id: true }, + }); + return { + path, + surface: "unit", + unitId: unit?.id ?? null, + lessonId: null, + practiceExerciseId: null, + }; + } + // .../ejercicios/ if (rest[0] === "ejercicios" && rest.length >= 2) { const exercise = await db.practiceExercise.findFirst({ @@ -92,6 +112,7 @@ export async function resolveFeedbackContext( return { path, surface: "practice", + unitId: null, lessonId: null, practiceExerciseId: exercise?.id ?? null, }; diff --git a/src/features/lessons/components/lesson-viewer.tsx b/src/features/lessons/components/lesson-viewer.tsx index bacf606..7ce59c6 100644 --- a/src/features/lessons/components/lesson-viewer.tsx +++ b/src/features/lessons/components/lesson-viewer.tsx @@ -14,6 +14,7 @@ import { useStudySession, } from "@/features/analytics/telemetry"; import { ReportBugDialog } from "@/features/bug-reports/components/report-bug-dialog"; +import { ReportDiscrepancyButton } from "@/features/feedback/components/report-discrepancy-button"; import { completeStep, markStepAssisted } from "@/features/lessons/actions"; import type { LanguageId } from "@/lib/code-languages"; import { cn } from "@/lib/utils"; @@ -311,6 +312,8 @@ function LessonPlayer({
+ + { course: { slug: "csharp-poo-1" }, }, ]); + (db as FakeDb).seed("unit", [ + { id: "unit_cpp", slug: "u01", courseId: "c_cpp", course: { slug: "cpp-desde-cero" } }, + { id: "unit_cs", slug: "u01", courseId: "c_cs", course: { slug: "csharp-poo-1" } }, + ]); }); describe("sanitizePath", () => { @@ -63,11 +67,32 @@ describe("resolveFeedbackContext", () => { expect(context).toEqual({ path: "/app/u/u01/primer-programa", surface: "lesson", + unitId: null, lessonId: "lesson_cpp", practiceExerciseId: null, }); }); + it("deriva la unidad completa cuando no hay lección en la ruta", async () => { + const context = await resolveFeedbackContext(asPrisma, "/app/u/u01"); + expect(context).toEqual({ + path: "/app/u/u01", + surface: "unit", + unitId: "unit_cpp", + lessonId: null, + practiceExerciseId: null, + }); + }); + + it("la ruta canónica con curso resuelve la unidad de ESE curso", async () => { + const context = await resolveFeedbackContext( + asPrisma, + "/app/c/csharp-poo-1/u/u01", + ); + expect(context.surface).toBe("unit"); + expect(context.unitId).toBe("unit_cs"); + }); + it("deriva el ejercicio de práctica", async () => { const context = await resolveFeedbackContext( asPrisma, @@ -123,6 +148,7 @@ describe("resolveFeedbackContext", () => { expect(await resolveFeedbackContext(asPrisma, undefined)).toEqual({ path: null, surface: null, + unitId: null, lessonId: null, practiceExerciseId: null, });