Repository navigation
chore(proyecto): adaptar al estándar con pnpm, seguridad y revisión completa - #1
Merged
Merged
Conversation
QUÉ CAMBIA - pnpm-lock.yaml generado con `pnpm import` desde package-lock.json (mismas versiones resueltas); se borra package-lock.json. - packageManager fijado a pnpm@11.24.0. - pnpm-workspace.yaml con allowBuilds: solo @prisma/engines y prisma pueden ejecutar scripts de instalación. - Dockerfile: stage base con Corepack e instalación con --frozen-lockfile. - CI: pnpm/action-setup, Node 22 y paso de pnpm audit (prod, nivel high). - Documentación: comandos npm/npx sustituidos por pnpm. VERIFICADO - pnpm lint, typecheck, 23 tests unitarios y 20 de integración en verde. - docker build completo y arranque del contenedor: migrate deploy y /api/health responden. PENDIENTE - pnpm audit informa de 63 avisos (2 críticos en next); se corrigen en el commit siguiente.
QUÉ CAMBIA - Subidas dentro de la misma versión mayor: next y eslint-config-next 16.3.6, Prisma 7.10.0, React 19.3.0, zod 4.6.5, pg 8.23.0, jose 6.2.12, Tailwind 4.3.3, Vitest 4.1.11 y tipos. - overrides en pnpm-workspace.yaml para transitivas: brace-expansion, browserslist y mysql2. - auditConfig ignora GHSA-ggr8-5vv4-36mx (deepmerge-ts en @prisma/config): solo fusiona la configuración local y el parche exige una mayor. POR QUÉ pnpm audit daba 63 avisos, 2 críticos y 30 altos, sobre todo en next. VERIFICADO - pnpm audit: 0 avisos (1 ignorado y documentado). - lint, typecheck, 23 tests unitarios, 20 de integración y build en verde.
QUÉ CAMBIA - Barra superior: en móvil «Administración» pasa a icono con aria-label, el nombre de la instancia se trunca y se reducen los huecos. - Cabecera del tablero con flex-wrap para que «Miembros» no quede fuera. - Opción activa del conmutador Tablero/Calendario con tokens del tema (antes blanco sobre blanco en modo oscuro). - El área autenticada usa flex-1: el fondo del tablero llega al final. - Mes del calendario con first-letter:uppercase («Septiembre de 2026»). VERIFICADO Playwright a 375 px: desbordamiento horizontal 0 en /boards, tablero, /settings, /admin/users, /admin/audit y /search (antes 76 px en todas). Capturas en modo oscuro y claro a 1440 px.
… teclado QUÉ CAMBIA - AddListForm y AddCardForm descartan envíos mientras la acción anterior sigue en curso (ref pending + preventDefault en onSubmit). - DndContext recibe un id estable (useId) para que aria-describedby coincida entre servidor y cliente. - KeyboardSensor con sortableKeyboardCoordinates: las tarjetas y listas se pueden mover con el teclado. POR QUÉ En el navegador se crearon dos listas iguales con 48 ms de diferencia y la consola mostraba un error de hidratación de dnd-kit en cada carga. VERIFICADO Playwright: tres Enter seguidos crean una sola lista; consola sin errores.
QUÉ CAMBIA - proxy.ts genera un nonce por petición y la CSP (app/lib/csp.ts): scripts propios con nonce y strict-dynamic, sin eval en producción, frame-ancestors, object-src, base-uri y form-action cerrados. - El layout raíz aplica el nonce al script inline del tema. - next.config.ts: HSTS (max-age=31536000, sin includeSubDomains), CSP default-src 'none' para /api y Permissions-Policy sin interest-cohort. - Tests unitarios de las directivas que no se pueden relajar. TRAMPAS - Sin upgrade-insecure-requests: rompería las instalaciones por http://IP. - style-src-attr 'unsafe-inline' es necesario por los atributos style que React pinta en el servidor. - En desarrollo los <style> van sin nonce (Turbopack y el overlay). VERIFICADO Playwright contra el build de producción: cero violaciones de CSP, tema oscuro aplicado y tiempo real entre pestañas funcionando. 29 tests unitarios, lint y typecheck en verde.
QUÉ CAMBIA - Migración 20260924000000_position_collate_c: List, Card, Checklist y ChecklistItem.position pasan a TEXT COLLATE "C". Reconstruye Card_listId_position_idx; no reescribe valores. - Comentario en schema.prisma (List.position): Prisma no modela la collation y migrate diff no ve deriva. - ADR 0002 ampliado en «Consecuencias» con el porqué y la consulta para detectar claves ya repetidas. - Test de integración position-collation.int.test.ts. POR QUÉ Las claves de fractional indexing solo se ordenan bien byte a byte. En una BD glibc (imagen Debian de postgres del CI, Ubuntu, servicios gestionados) en_US.utf8 deja 'Zz' después de 'a0l': la tarjeta arrastrada al principio reaparecía al final y createCard/createList generaban claves repetidas al tomar como última una vecina equivocada. VERIFICADO - lint, typecheck, test unitarios y de integración (orgtable_l9_test). - migrate diff contra el esquema: sin diferencias. - En una BD temporal ICU sin el arreglo los tres tests fallan; con COLLATE "C" pasan. En alpine (musl) solo falla la comprobación de collation, porque allí el orden ya era byte a byte. TRAMPAS - Las instalaciones glibc que ya tengan claves repetidas las conservan; reasignarlas es decisión del propietario, con copia previa. - Toda columna de posición nueva necesita COLLATE "C" a mano.
QUÉ CAMBIA
- ADR 0002 y docs/DECISIONES.md: el fallo por collation lingüística
también afecta a addChecklist/addChecklistItem y aparece solo con altas
al final (tras 'aZ' viene 'aa', que en_US.utf8 ordena antes de
'aB'...'aZ'; desde el elemento 38 se repite 'aa').
- Consulta de detección de claves repetidas ampliada a Checklist
("cardId") y ChecklistItem ("checklistId").
- Test de integración: createChecklist y addChecklistItem con 37 claves
a0...aa no repiten claves (requireBoardAccess mockeado solo en ese
archivo, porque las acciones exigen sesión).
VERIFICADO
- lint, typecheck, test unitarios e integración (orgtable_l9_test).
- Con Checklist/ChecklistItem.position en COLLATE "en-US-x-icu" el test
nuevo falla; con COLLATE "C" pasa. BD de pruebas restaurada a "C".
TRAMPAS
- El comentario de la migración ya aplicada no se toca para no cambiar su
checksum; el porqué completo queda en el ADR 0002.
QUÉ CAMBIA - next.config.ts: experimental.serverActions.bodySizeLimit y proxyClientMaxBodySize a 11 MB. - docs/security.md: límite documentado como conocido (global y previo a la autenticación) y fila de cabeceras actualizada con CSP y HSTS. - test/next-config.test.ts: límite por encima de MAX_ATTACHMENT_BYTES más la sobrecarga multipart, límite del proxy alineado y cabeceras fijas en /, /login y /api/v1. POR QUÉ Next corta el cuerpo de una Server Action en 1 MB antes de ejecutarla: los adjuntos (10 MB) y las importaciones (5 MB) de más de 1 MB fallaban. El proxy guarda solo 10 MB del cuerpo y lo demás llegaba truncado. VERIFICADO pnpm lint, typecheck, test y test:integration. Falta la prueba manual con build + start (subir 3 MB y 10,5 MB), que hace el integrador.
Cubre el matcher de proxy.ts (páginas sí; /api, estáticos y .svg no), que /login y / con sesión reciben la CSP con el mismo nonce que se reenvía al layout en x-nonce, y la redirección a /login sin sesión.
QUÉ CAMBIA - app/layout.tsx: plantilla de título «%s · orgtable» y meta description «Tableros Kanban self-hosted, una alternativa a Trello». - /login («Iniciar sesión») y /setup («Configuración inicial») exportan su título. - Login: el párrafo deja de decir open-source y el pie «Open source · AGPL-3.0» pasa a «Kanban · Self-hosted». - test/metadata.test.ts: títulos resueltos, description y HTML del login sin open source ni AGPL. POR QUÉ Todas las rutas compartían el título (WCAG 2.4.2) y la interfaz afirmaba una licencia AGPL que no es la vigente (LICENSE, propietaria). VERIFICADO pnpm lint, typecheck y test. Falta comprobar en el navegador que document.title cambia al navegar (lo hace el integrador con build).
QUÉ CAMBIA - app/lib/prisma.ts: buildPoolConfig() con connectionTimeoutMillis 5 s e idleTimeoutMillis 30 s; onPoolError registra solo el mensaje del error. - El PrismaClient se guarda en globalThis también en producción. - test/integration/prisma-pool.int.test.ts: timeout con el pool agotado y mismo cliente entre dos evaluaciones del módulo en producción. POR QUÉ Con los valores por defecto de pg, una petición sin conexión libre esperaba para siempre (Prisma 7 ya no aplica connect_timeout). Turbopack evalúa el módulo una vez por capa y en producción se abrían dos pools. VERIFICADO pnpm lint, typecheck, test y test:integration contra orgtable_l5_test. El test del singleton falla con la condición anterior. Falta comprobar tras build + start que pg_stat_activity no duplica conexiones.
QUÉ CAMBIA
- app/lib/prisma.ts: idleTimeoutMillis vuelve a 10 s, el valor por
defecto de pg-pool (el comentario decía que 30 s era el de pg). El
comentario de onPoolError explica su motivo real: el adapter ya
escucha el evento 'error' del pool; el manejador deja el fallo en el
log, solo con el mensaje.
- test/integration/prisma-pool.int.test.ts: nuevo test que envuelve
PrismaPg con vi.doMock y comprueba que el cliente exportado se crea
con buildPoolConfig() y que onPoolError registra solo el mensaje.
- docs/DECISIONES.md: entrada del pool corregida.
POR QUÉ
El test de timeout sobrescribe el config y construye su propio
PrismaPg: seguía en verde si createPrismaClient() volvía a
new PrismaPg({ connectionString }).
VERIFICADO
pnpm lint, typecheck, test y test:integration contra
orgtable_l5_test. El test nuevo falla con el cableado anterior.
QUÉ CAMBIA - next.config.ts: regla /:file(.*\\.svg) con default-src 'none'; style-src 'unsafe-inline'; frame-ancestors 'none'. - test/next-config.test.ts: headersFor resuelve las reglas con buildCustomRoute del router de Next (entiende parámetros con regex) y comprueba /file.svg e /icons/logo.svg. - docs/security.md y docs/DECISIONES.md: CSP de los .svg documentada. POR QUÉ proxy.ts excluye los .svg de su matcher y next.config.ts solo ponía CSP a /api: los SVG de public/ se servían sin CSP y, abiertos directamente, podían ejecutar scripts en el origen de la app. VERIFICADO pnpm lint, typecheck, test y test:integration. Los casos .svg fallan sin la regla. Falta comprobar con build + start que curl -I /file.svg devuelve la cabecera (lo hace el integrador). TRAMPAS file.svg, globe.svg, next.svg, vercel.svg y window.svg de public/ son restos de la plantilla de Next sin uso en app/; no se borran.
QUÉ CAMBIA - Nuevo helper puro firstParam() en app/lib/search-params.ts que devuelve el primer valor cuando la clave se repite en la URL. - /search y /board/[id] tipan searchParams como string | string[] y lo usan para q y card. POR QUÉ Con /search?q=a&q=b, q llegaba como array y q.trim() lanzaba un error que tumbaba la página. VERIFICADO test/search-params.test.ts (string, array, array vacío y undefined), pnpm lint y pnpm typecheck.
QUÉ CAMBIA - Nuevo componente cliente DeleteBoardButton: el ✕ pasa a «¿Borrar? Sí / No» en línea y solo «Sí» envía deleteBoard. Foco en «No», Escape cancela y «Sí» se desactiva mientras se envía. - /boards solo lo pinta si el usuario es el dueño del tablero. - Exportar y borrar se ven siempre en pantallas sin hover y con el foco dentro de la tarjeta (antes solo con el ratón encima). POR QUÉ Un solo clic, o un toque en la esquina en táctil donde el botón era invisible, borraba el tablero en cascada. A los miembros se les mostraba un botón que no hacía nada. VERIFICADO test/delete-board-button.test.ts (el estado inicial no tiene formulario ni botón submit), pnpm lint y pnpm typecheck. La interacción del segundo clic y la vista táctil no se han probado en navegador.
QUÉ CAMBIA - globals.css: --danger claro 0.55→0.52, --success claro 0.57→0.48 y --danger oscuro 0.70→0.71 (DESIGN.md al día). - Placeholder de Input/Textarea a text-ink-muted (antes /70, ~3,2:1). - aria-label en el campo de nuevo tablero y en el de importar JSON; role="alert" en el error de importación. - title con el nombre completo de la instancia, que se trunca en móvil. POR QUÉ --success daba 3,86:1 sobre el fondo y 3,72:1 en sus avisos; --danger quedaba en 4,48:1 en sus avisos y 4,15:1 en la insignia de vencida. Los campos sin etiqueta y el error mudo no llegaban al lector de pantalla. VERIFICADO Nuevo test/contrast.test.ts que lee los tokens del CSS y exige 4,5:1 en los dos temas (falla con el --success anterior). pnpm lint, pnpm typecheck y pnpm test. No se ha revisado en navegador ni con lector de pantalla.
QUÉ CAMBIA - /boards («Tus tableros») y /search («Búsqueda») exportan metadata. La consulta de búsqueda no entra en el título. - /board/[id] añade generateMetadata con el nombre del tablero, leído con getBoardTitleForUser (React.cache). Esa función y getBoardWithLists comparten el filtro boardAccessWhere (dueño o miembro). Sin acceso, o si el tablero no existe, el título es «Tablero». POR QUÉ Todas las rutas tenían el mismo <title>. El título del tablero se resuelve aparte de la página, así que sin la misma guarda filtraría el nombre de un tablero ajeno. VERIFICADO test/integration/board-metadata.int.test.ts (dueño y miembro ven el nombre, no miembro e id inexistente ven «Tablero»), pnpm lint, pnpm typecheck, pnpm test y pnpm test:integration.
QUÉ CAMBIA - DeleteBoardButton guarda una ref al ✕ y un indicador que solo activa la cancelación («No» o Escape); un useEffect sobre confirming lo consume y devuelve el foco al ✕. «Sí» sigue enviando igual. POR QUÉ Al cancelar, el formulario se desmontaba con el foco dentro y este caía al <body>: con teclado o lector de pantalla se perdía el sitio en la rejilla de tableros, y el ✕ quedaba con opacity-0 al no haber foco en la tarjeta. VERIFICADO test/delete-board-button-focus.test.ts (arnés local de hooks, sin jsdom): falla con la versión anterior y comprueba que el montaje inicial no roba el foco. pnpm lint, pnpm typecheck, pnpm test y pnpm test:integration. No probado en navegador ni con lector de pantalla.
QUÉ CAMBIA - El modal es un <dialog> nativo abierto con showModal(): fondo inerte, foco inicial dentro, Escape por onCancel, clic fuera cierra y el foco vuelve a la tarjeta al cerrar. Nombre accesible con aria-labelledby. - La hora de los comentarios se pinta solo tras hidratar, en la zona del navegador y es-ES, con <time dateTime>. El vencimiento de solo lectura se muestra legible (es-ES, zona UTC) en vez del ISO crudo. - Los toggles de etiqueta e ítem avisan con role="alert" si fallan; los errores internos de Next se relanzan con unstable_rethrow. - Borrar tarjeta pide un segundo clic en línea («¿Borrar la tarjeta?»). - Botones de borrar visibles con foco de teclado y en pantallas táctiles. - Etiquetas en los campos (label de la descripción, aria-label en el resto), role="alert" en el error de adjuntos, etiquetas no seleccionadas sin opacidad (contraste AA) y con aria-pressed, colores nombrados en español y «N de M completadas». POR QUÉ Hallazgos del lote 11 de la revisión: foco sin gestionar, error de hidratación al abrir por ?card=, contraste ~2,2:1, borrado sin confirmación y fallos silenciosos. VERIFICADO pnpm lint, typecheck, test (nuevo test/card-modal-helpers.test.ts) y test:integration en verde. Las clases nuevas de Tailwind se generan. El comportamiento del diálogo en el navegador no se ha probado a mano.
QUÉ CAMBIA - El aviso de fallo al cambiar una etiqueta sale bajo la sección Etiquetas; el de los ítems, bajo los ítems de cada checklist (estado propio en ChecklistBlock). Antes ambos salían arriba del modal. - La etiqueta no seleccionada vuelve al relleno de color: solo se quita opacity-55. El estado se distingue por el ✓, el anillo y aria-pressed. - El fundido del fondo usa backdrop:motion-safe:, porque la regla de prefers-reduced-motion de globals.css no cubre ::backdrop. - Se quita el test «usa el locale es-ES» (con «es» Node da el mismo texto) y el comentario ya no atribuye a estos tests la hidratación. POR QUÉ Revisión del lote 11: con el modal desplazado el aviso de un ítem no se veía, el nuevo estilo de etiqueta era un cambio de diseño no pedido y el fondo animaba aunque el usuario pidiera movimiento reducido. VERIFICADO pnpm lint, typecheck, test e integración en verde. Compilado el CSS con @tailwindcss/postcss: la clase genera ::backdrop dentro de @media (prefers-reduced-motion: no-preference). La colocación de los avisos no tiene test automático (no hay jsdom).
QUÉ CAMBIA - notify() devuelve Promise<boolean>: true en cuanto la fila se guarda (aunque falle después la publicación en el bus, que va en su propio try) y false si la inserción falla. - El catch vacío pasa a registrar con console.error el tipo, los ids y el código de error, nunca el título ni el cuerpo. POR QUÉ El fallo se tragaba en silencio y el cron de recordatorios contaba como creado un aviso que no existía. VERIFICADO test/integration/notifications.int.test.ts: con un userId inexistente devuelve false, no crea filas y el log no contiene el texto.
QUÉ CAMBIA - Nuevo removeMemberFromBoard (app/lib/services/members.ts): borra la membresía y las asignaciones del usuario en ese tablero en una transacción. - removeBoardMember y updateBoardMemberRole no auditan ni notifican si no cambió ninguna fila (usuario ajeno, expulsión repetida o mismo rol). - addComment solo avisa a asignados que siguen siendo miembros, lo que neutraliza las asignaciones huérfanas que ya existen. - Las tres acciones de miembros usan revalidateBoard (revalida y publica en el bus de tiempo real) en vez de revalidatePath. - addBoardMember devuelve values con lo enviado cuando hay error. POR QUÉ Un miembro expulsado seguía asignado y recibía el título de las tarjetas y el texto de los comentarios. Con un userId ajeno se creaban notificaciones y auditorías de cambios que no habían ocurrido. Los demás miembros no veían en vivo los cambios de miembros. VERIFICADO test/integration/members.int.test.ts (servicio, conteos de Notification y AuditLog, eventos del bus, values del formulario) y un caso nuevo en collaboration.int.test.ts con una asignación huérfana. Las acciones se prueban con vi.mock de requireBoardAccess local a cada archivo. TRAMPAS Las asignaciones huérfanas previas siguen en la BD; limpiarlas lo decide el propietario.
QUÉ CAMBIA - El inicio del día se calcula en UTC y la fecha del aviso se formatea con es-ES y timeZone UTC: dueDate es una fecha de calendario a medianoche UTC. - Las vencidas solo se avisan si vencieron en los últimos 7 días, y una sola vez a partir del día UTC siguiente al vencimiento. «Vence pronto» sigue siendo como mucho un aviso diario. - Solo se avisa a asignados que siguen siendo miembros del tablero. - created solo suma los avisos que notify() guardó. - Tests nuevos del endpoint cron (503, 401 con secretos de otra longitud o esquema, 200 idempotente) sin tocar route.ts. - El test de recordatorios usa un único now por test en vez de uno calculado al importar, para no romperse si cruza la medianoche UTC. POR QUÉ Una tarjeta vencida avisaba cada día para siempre y la pasada crecía sin tope. Con el servidor en una zona negativa, una tarjeta que vence hoy salía como vencida y con el día anterior en el texto. VERIFICADO reminders.int.test.ts (TZ=America/New_York, vencida avisada una sola vez, ventana de 7 días, asignado expulsado) y cron-reminders.int.test.ts. Los casos nuevos de recordatorios fallan con el código anterior.
QUÉ CAMBIA - El panel pasa a un <dialog> abierto con showModal(): foco inicial dentro, resto de la página inerte, Escape y clic en el fondo llaman a onClose, y al desmontar se devuelve el foco a quien lo tenía. Se quita el listener de Escape en window. - aria-labelledby y aria-describedby con useId hacia el título y la descripción. - El error se anuncia con role="alert" y el mensaje de éxito dentro de una región role="status" siempre montada. - Email y rol usan defaultValue con los values que devuelve la acción al fallar, así el email no se vacía. - Errata en un comentario: «revuelve» por «resuelve». POR QUÉ El div role="dialog" no gestionaba el foco ni tenía nombre accesible, y los avisos del formulario no llegaban a los lectores de pantalla. VERIFICADO lint y typecheck. El comportamiento de values está cubierto en members.int.test.ts. TRAMPAS Falta la verificación manual con teclado y lector de pantalla (Tab en bucle, Escape, foco devuelto); no hay infraestructura e2e.
QUÉ CAMBIA - ADR 0015: nota «Modificado el 2026-09-24» que remite a docs/reminders.md y docs/DECISIONES.md. - docs/roadmap.md y CLAUDE.md: «idempotente por día» se sustituye por un enlace a docs/reminders.md. POR QUÉ Seguían describiendo la dedup diaria antigua, que contradice la política actual (día UTC, un único aviso de vencida, ventana de 7 días). La política queda documentada en un solo sitio.
QUÉ CAMBIA - Nuevo app/lib/realtime/sse.ts (createSseResponse) que comparten los streams del tablero y de notificaciones: limpieza idempotente (latido, suscripción al bus y cierre) desde el abort, cancel(), un enqueue fallido, la revalidación y la vida máxima. - Si la petición llega ya abortada, 204 sin suscribirse. - En cada latido de 25 s se comprueba que la sesión no está revocada ni caducada y, en el tablero, que se sigue siendo miembro; si no, o si la BD falla, se cierra el stream. - Vida máxima de 15 min para forzar la reautorización con la cookie. POR QUÉ Un abort durante los await de autorización o un enqueue que lanzaba dejaba el intervalo y el listener del bus vivos para siempre. Y una sesión revocada o una persona expulsada seguían recibiendo eventos, en contra del ADR 0008. VERIFICADO test/integration/realtime.int.test.ts: 401/403, eventos recibidos, abort, petición ya abortada, cancel del body, revocación, expulsión y vida máxima; tras cerrar no quedan listeners ni temporizadores. lint, typecheck, tests unitarios y de integración en verde.
QUÉ CAMBIA - Si LISTEN falla o la conexión da error o se cierra, el cliente pg se cierra y se registra con console.error (sin DATABASE_URL). - La promesa de arranque vive en globalThis y publishCrossInstance la espera: ya no se pierden eventos mientras el puente arranca. - Reconexión con backoff exponencial de 1 a 30 s, un único temporizador y solo si quedan suscriptores. - parseBridgePayload valida el NOTIFY con zod; lo inválido se descarta con console.warn indicando solo la longitud. - Los .catch de publicación registran kind e id en vez de callar. POR QUÉ Con el puente activado (ORGTABLE_REALTIME_PG_NOTIFY=1), un fallo dejaba la conexión abierta y cada SSE abría otra hasta agotar el pool, sin dejar rastro. Un payload sin id se reemitía en "board:undefined". VERIFICADO test/realtime-bus.test.ts con pg simulado: end() y console.error al fallar LISTEN, backoff 1 s / 2 s, sin reintentos sin suscriptores, reconexión tras error, publicación durante el arranque y descarte de payloads inválidos. No probado contra una caída real de Postgres.
QUÉ - setup-wizard.md sustituye el fragmento antiguo de la transacción (lanzaba ALREADY_SETUP y no tenía lock) por los pasos de completeInstanceSetup: pg_advisory_xact_lock, recomprobación y SetupOutcome sin lanzar. - Añade el corte temprano con isSetupComplete() en la acción y renumera los pasos. - La «triple defensa» deja de presentar la recomprobación como suficiente: solo protege porque el lock serializa los envíos. POR QUÉ docs/api/server-actions.md ya lo contaba así y los dos documentos se contradecían; quien siguiera setup-wizard.md podía quitar el lock y reintroducir la carrera del doble administrador. El porqué se enlaza a la entrada de docs/DECISIONES.md en vez de repetirlo. VERIFICADO Contrastado con app/lib/services/setup.ts y app/lib/actions/setup.ts. pnpm lint, pnpm typecheck y pnpm test pasan.
QUÉ - El DragOverlay del calendario se pinta con createPortal a document.body, montado solo en el navegador (useSyncExternalStore) y siempre, para que termine la animación de soltar. POR QUÉ - El panel del calendario lleva backdrop-blur-sm. backdrop-filter lo convierte en el bloque contenedor de los descendientes fixed, así que la copia (fixed, con coordenadas de la ventana) salía desplazada por la cabecera, volvía a un punto equivocado al soltar y la recortaba el overflow-y-auto. VERIFICADO - pnpm lint, pnpm typecheck y pnpm test en verde. - Test nuevo test/calendar-view.test.ts: el render de servidor no toca document (falla con ReferenceError si se quita la guarda). - Sin comprobar a mano en el navegador (con ratón y con teclado).
QUÉ - La cabecera del calendario pasa a flex-wrap y el aviso role="alert" usa order-last basis-full, igual que el aviso de movimiento del tablero. POR QUÉ - Entre el título del mes y la navegación, a 375 px al aviso le quedaban unos 10 px: se partía palabra a palabra, se salía sobre los botones y la cabecera crecía unos 150 px. VERIFICADO - pnpm lint, pnpm typecheck y pnpm test en verde. - Sin comprobar a mano a 375 px (no hay DOM en los tests).
QUÉ - Si la pastilla se puede arrastrar, su onKeyUp cancela Espacio (preventSpaceActivation). Sin permiso de edición no cambia nada. POR QUÉ - dnd-kit solo cancela el keydown de Espacio al coger y al soltar, pero un <button> nativo se activa en el keyup (Firefox lo hace aunque el keydown esté cancelado): coger o soltar la pastilla podía abrir además el modal. VERIFICADO - pnpm lint, pnpm typecheck y pnpm test en verde. - Tests nuevos en test/calendar-view.test.ts: Espacio se cancela; Intro y las flechas no. - Sin comprobar a mano en Firefox.
QUÉ - El aviso «No se pudo cambiar la fecha…» se describe en su propia fila, bajo el mes y la navegación, y no «junto al mes». - Se explica por qué la copia del DragOverlay se pinta en un portal a document.body: el panel lleva backdrop-blur y el backdrop-filter crea un bloque contenedor que desplazaría la copia fixed. POR QUÉ Los commits 118c681 y 2ca0dc7 cambiaron ese comportamiento sin actualizar docs/calendar.md, donde se documenta. VERIFICADO pnpm lint, pnpm typecheck y pnpm test pasan. Solo cambia documentación.
QUÉ El <select name="role"> del formulario de invitar lleva ahora una key con el rol devuelto por la acción, y ese rol solo se reutiliza si está en ASSIGNABLE_BOARD_ROLES (si no, vuelve a Editor). POR QUÉ React restablece el formulario tras la acción, pero no aplica el nuevo defaultValue a un <select> ya montado. Tras un error («no existe ninguna cuenta…») el rol volvía a Editor sin avisar y, al corregir el email, la persona entraba con permisos de escritura aunque se hubiera elegido Lector. Es el mismo patrón que create-user-form.tsx y create-token-form.tsx. VERIFICADO pnpm lint, pnpm typecheck y pnpm test. test/members-panel.test.ts comprueba en el HTML la opción marcada tras un error y el retorno a Editor con un rol no asignable. El remontaje por key no se ve sin DOM (el proyecto no tiene jsdom): queda sin test automático.
QUÉ El aviso role="alert" del formulario de invitar se retira mientras se envía (state.error && !pending), como en login-form.tsx y setup-form.tsx. POR QUÉ Con el nodo siempre montado, un segundo error con el mismo texto (p. ej. invitar dos veces a alguien que ya es miembro) no cambiaba nada en el DOM y el lector de pantalla no lo anunciaba, así que no se sabía si el segundo intento había fallado. VERIFICADO pnpm lint, pnpm typecheck y pnpm test. test/members-panel.test.ts comprueba que el aviso está con el error y desaparece mientras pending.
QUÉ El ::backdrop del diálogo de miembros usa backdrop:motion-safe:animate-[overlay-in_150ms_ease-out], igual que card-modal.tsx. POR QUÉ La regla prefers-reduced-motion de globals.css no cubre ::backdrop, así que el fundido del fondo se veía aunque el sistema pidiera reducir el movimiento. VERIFICADO pnpm lint, pnpm typecheck y pnpm test. test/members-panel.test.ts comprueba la clase del diálogo en el HTML.
QUÉ Entrada nueva sobre el rol al invitar tras un error (key validada contra ASSIGNABLE_BOARD_ROLES), el aviso retirado mientras se envía y el fondo con motion-safe. Se corrigen las dos líneas de la entrada «Panel de miembros con <dialog> nativo» que ya no describían el código. POR QUÉ Los tres commits anteriores del panel no registraron sus decisiones y la entrada vigente contradecía el código (defaultValue sin key, aviso siempre montado).
QUÉ El aviso role="alert" de crear usuario, crear token e importar tablero se retira mientras se envía (state.error && !pending), como en login-form.tsx y members-panel.tsx. En crear usuario, aria-describedby del email solo apunta al aviso cuando está visible. POR QUÉ Con el nodo siempre montado, un segundo error con el mismo texto (crear dos veces un usuario con un email existente, importar dos veces el mismo JSON inválido) no cambiaba el DOM y el lector de pantalla no lo anunciaba. Completa el hallazgo 14, que solo se había aplicado al panel de miembros. VERIFICADO pnpm lint, pnpm typecheck y pnpm test. test/form-error-reannounce.test.ts falla con el código anterior (4 de 4) y pasa con el nuevo.
QUÉ - Al pulsar «No» en la confirmación de borrar lista, el foco vuelve al ✕ de esa lista en vez de caer en <body>. - Escape dentro de la confirmación la cancela y no se propaga a otros manejadores del tablero. POR QUÉ El formulario de confirmación se desmontaba con el foco dentro y quien usaba teclado perdía su sitio en el tablero. Se aplica el mismo patrón que DeleteBoardButton: ref al ✕ y un efecto que le devuelve el foco solo tras cancelar, para que al montar el tablero ningún ✕ robe el foco. VERIFICADO test/list-column-delete.test.ts (arnés de hooks sin DOM, como el de delete-board-button): 3 de sus 4 casos fallan sin el arreglo. lint, typecheck y pnpm test en verde.
QUÉ El «Sí» de la confirmación de borrar lista anuncia «Sí, borrar la lista X y todas sus tarjetas» en vez de «y sus N tarjetas». POR QUÉ Con filtros activos BoardView pasa a ListColumn solo las tarjetas visibles: una lista con 12 tarjetas y una visible anunciaba «y sus 1 tarjetas» (cifra falsa y plural erróneo), y al confirmar se borraban las 12 en cascada. Se descarta pasar el total real desde BoardView (decisión en docs/DECISIONES.md). VERIFICADO Nuevo caso en test/list-column-delete.test.ts, que falla sin el arreglo. lint, typecheck y pnpm test en verde.
QUÉ - keepEventSourceOpen cambia onClosed por probe + onAccessDenied. Al quedar CLOSED, probeStatus hace un GET al mismo endpoint que se aborta al llegar las cabeceras, y solo con 401/403 se refresca para redirigir. - Ante 5xx, error de red o fallo de la comprobación solo se reintenta con backoff; al reabrirse, onReconnect resincroniza. POR QUÉ En Next 16 un router.refresh() cuya petición RSC recibe una respuesta no OK hace una navegación completa. Con el 502 del proxy durante update.sh, o un 500 por un fallo pasajero de la BD, cada pestaña acababa en la página de error y se perdía lo no guardado. VERIFICADO pnpm lint, pnpm typecheck y pnpm test. Tests nuevos: 5xx y error de red no avisan, 401/403 sí, desmontaje durante la comprobación, y probeStatus contra un servidor HTTP real (estado sin esperar al cuerpo del stream). PENDIENTE docs/realtime.md aún describe el refresh al quedar CLOSED.
QUÉ scheduleRefresh (app/lib/realtime/reconnect.ts) es un debounce de 200 ms a nivel de módulo que comparten BoardRealtime y NotificationsBell para eventos, reconexiones y acceso denegado. Sustituye al temporizador local de cada componente. No se cancela al desmontar para no anular el refresh que pidió el otro componente. POR QUÉ router.refresh() repinta todo el árbol, layout incluido. En /board/[id] los dos streams caen y vuelven a la vez (vida máxima de 15 min, reinicio, suspensión), así que cada reconexión hacía dos renders completos del tablero en lugar de uno. VERIFICADO pnpm lint, pnpm typecheck y pnpm test. Test nuevo: dos peticiones dentro de la ventana dan un solo refresh y una fuera de ella da otro.
QUÉ - keepEventSourceOpen pasa una AbortSignal a probe y la hace competir con un temporizador de PROBE_TIMEOUT_MS (10 s). Al vencer, aborta la señal y reintenta con backoff como ante un error de red, sin onAccessDenied. - El desmontaje aborta la comprobación en curso. - probeStatus(url, signal) devuelve null si la señal se aborta. POR QUÉ El reintento solo se programaba al terminar probe(), y fetch no tiene timeout en el navegador. Si el proxy retenía la conexión o el servidor no respondía (p. ej. esperando al pool de la BD), la fuente no se recreaba nunca y el tiempo real se perdía hasta recargar la pestaña. VERIFICADO pnpm lint, pnpm typecheck y pnpm test. Tests nuevos: probe que no resuelve nunca (tras el plazo se recrea la fuente sin avisar), aborto al desmontar, y probeStatus con la señal abortada contra un servidor HTTP real. Los cuatro fallan con el código anterior.
QUÉ docs/realtime.md describía que, al quedar CLOSED, el cliente hacía router.refresh(). Ahora explica probeStatus, que solo 401/403 llaman a onAccessDenied (refresh agrupado con scheduleRefresh), que 5xx, errores de red y fallos de la comprobación solo reintentan con backoff de 3 s a 30 s, el plazo de 10 s de la comprobación y el debounce compartido con la campana. Se quita el «pendiente» de docs/DECISIONES.md. POR QUÉ La documentación de referencia del tiempo real contradecía el código desde el cambio que dejó de refrescar a ciegas: en Next 16 un refresh con respuesta no OK hace una navegación completa a la página de error. VERIFICADO Contrastado con app/lib/realtime/reconnect.ts, board-realtime.tsx y notifications-bell.tsx.
QUÉ - El test de formatDueDay fija TZ=America/New_York con vi.stubEnv, llama a vi.resetModules() e importa board-filters de forma dinámica. - afterEach usa vi.unstubAllEnvs() en vez de reasignar process.env.TZ. POR QUÉ - El formateador de board-filters es de módulo y fija la zona al importarse. Con el import estático se quedaba con la zona de la máquina (UTC en CI) y el test pasaba aunque se quitara `timeZone: "UTC"`. - Reasignar TZ = undefined guardaba el texto "undefined", que Node trata como UTC para el resto del archivo. VERIFICADO - Quitando temporalmente `timeZone: "UTC"` el test falla con «23 sept» (TZ=UTC). - Un test temporal posterior confirma que TZ ya no queda definida.
QUÉ - El test «sin cuenta tarda del mismo orden que con cuenta» pasa a espiar bcrypt.compare y comprueba que verifyPasswordOrDummy(…, null) compara una vez contra DUMMY_PASSWORD_HASH. POR QUÉ - Medía una sola llamada de cada tipo y exigía dummy > real * 0.5: una pausa del GC o un runner de CI cargado lo hacía fallar sin cambios de código. - La propiedad se cubre de forma determinista: el espía asegura que se hace el trabajo de bcrypt y el primer test (60 caracteres, mismo coste) que ese trabajo cuesta lo mismo que con un hash real. VERIFICADO - Haciendo que verifyPasswordOrDummy devuelva false sin comparar cuando no hay cuenta, el test falla («called 0 times»).
- labels: se borra el envoltorio signIn(), que solo llamaba a loginAs(), y su comentario sobre una cookie que ya gestiona el stub compartido. - Se borran los vi.unstubAllEnvs() de siete archivos que ya no usan vi.stubEnv (labels, positions, cards-due-date, attachments-delete, attachments-download, notifications-read y realtime), y los imports que quedaban sin uso. Verificado: eslint y los 7 archivos de integración en verde (61 tests).
QUÉ
- board-metadata, members, collaboration, tokens, admin-users y
position-collation dejan de hacer vi.mock("@/app/lib/dal") y usan
loginAs() con usuarios sembrados. admin-users solo sustituye redirect().
- position-collation ya no devuelve el usuario inexistente "test-user": las
acciones de checklist se llaman con la sesión del propietario.
- members añade un test: un EDITOR o un VIEWER no puede invitar, cambiar
roles ni expulsar, y no queda notificación ni auditoría.
- docs/DECISIONES.md: la entrada del mecanismo de sesión deja de decir
«todos» sin serlo y recoge el cambio.
POR QUÉ
- Los comentarios de esos mocks decían que el stub de next/headers no tiene
cookies, lo que es falso desde d1e0cd0.
- Con el DAL sustituido, actAs() devolvía el rol que pedía el test y la
guarda de las acciones de miembros no se probaba.
VERIFICADO
- pnpm lint, pnpm typecheck y pnpm test:integration (32 archivos, 210 tests).
- Quitando temporalmente la guarda canManageBoard de actions/members.ts, los
dos tests nuevos fallan.
QUÉ - «Integración continua» incluye los tres pasos que van primero en ci.yml (shellcheck, variables del compose y referencias de licencia), con el comando para reproducirlos en local, y renumera la lista a 12 pasos. - Las tablas por archivo, a las que les faltaban 34 de 58 tests, se cambian por tablas por área que nombran todos los archivos actuales y remiten a `ls test test/integration` como inventario. - La descripción de los unitarios deja de prometer «lógica pura»: ops-scripts lanza bash con un docker falso, storage escribe en un directorio temporal y los de interfaz renderizan con react-dom/server. POR QUÉ - Un fallo en los pasos nuevos del CI no estaba explicado en la documentación, y las tablas por archivo se desfasaban con cada lote. VERIFICADO - Un script compara las tablas con `test/*.test.ts` y `test/integration/*.int.test.ts`: no falta ni sobra ningún archivo. - El comando de `docker compose config` documentado muestra las tres variables. shellcheck no está instalado en esta máquina: no se ejecutó.
QUÉ - vitest.config.ts: el comentario ya no dice que los unitarios cubren solo lógica pura; dice que no usan base de datos ni red y remite a docs/testing.md. - CLAUDE.md: «Unitarios (lógica pura, sin BD)» pasa a «(sin BD ni red)». POR QUÉ docs/testing.md ya explica que no todos son lógica pura (ops-scripts lanza bash con un docker falso, storage escribe en un directorio temporal). Los otros dos sitios lo contradecían; ahora el detalle vive solo en docs/testing.md. VERIFICADO pnpm lint, pnpm typecheck y pnpm test (26 archivos, 177 tests) en verde.
QUÉ - pickClientIp quita el puerto de "a.b.c.d:puerto" y "[v6]:puerto" antes de validar con isIP (sin cortar IPv6 sin corchetes). - Nueva resolveClientIp (pura) que devuelve la IP o el motivo de no tenerla. - pickClientIp avisa por console.warn una vez por motivo y proceso cuando TRUSTED_PROXY_HOPS > 0 y no hay IP, sin incluir la cabecera ni IPs. POR QUÉ Con proxies que añaden el puerto (Azure Application Gateway, IIS ARR) el valor no pasaba isIP: el login se quedaba sin límite por IP y la auditoría sin IP, sin ningún aviso. VERIFICADO pnpm lint, pnpm typecheck, pnpm test (tests nuevos de puerto, motivos y aviso único) y pnpm test:integration. PENDIENTE Documentar el aviso en la tabla de TRUSTED_PROXY_HOPS de docs/operations.md.
…a ui
QUÉ
- updateCard devuelve un estado para useActionState: {} si guarda, o el
error con la descripción y la fecha enviadas para rehidratar el formulario.
- El modal muestra el error, desactiva el botón mientras guarda y limita el
textarea con maxLength={CARD_DESCRIPTION_MAX}.
- El servidor normaliza \r\n a \n antes de validar, porque maxLength cuenta
cada salto de línea como un carácter.
- Una descripción igual a la guardada no se valida ni se reescribe: solo se
actualiza la fecha.
POR QUÉ
Con más de 5000 caracteres la acción volvía sin guardar ni avisar y el
reseteo del formulario borraba lo escrito. Las tarjetas con descripciones
anteriores al límite ya no podían cambiar el vencimiento desde el modal.
VERIFICADO
pnpm lint, pnpm typecheck, pnpm test y pnpm test:integration (casos nuevos:
error con 5001 caracteres, CRLF que mide 5000 en la UI, descripción heredada
larga). El comportamiento del formulario en el navegador no se ha probado
(no hay e2e).
PENDIENTE
- docs/card-detail.md sigue describiendo updateCard(formData).
- El modal importa validators.ts y arrastra zod al bundle del cliente.
QUÉ - El aviso [client-ip] decía «Solo se avisa una vez por proceso», pero sale una vez por motivo y proceso (hasta tres textos distintos). Ahora lo dice. - docs/operations.md: la fila N > 0 de TRUSTED_PROXY_HOPS explica que se acepta IP:puerto y [v6]:puerto, qué pasa si no hay IP válida (sin límite por IP, AuditLog.ip vacío) y el aviso del log. - docs/testing.md: la fila de test/client-ip.test.ts incluye puerto, motivos y aviso único. - docs/DECISIONES.md: quita el «queda pendiente» ya resuelto. POR QUÉ Con el texto anterior el operador podía tomar un segundo aviso con otro motivo por un fallo nuevo, y sin la documentación no sabía qué significaba. VERIFICADO pnpm lint, pnpm typecheck, pnpm test (el test del aviso comprueba el texto) y pnpm test:integration.
QUÉ - CARD_DESCRIPTION_MAX pasa a app/lib/limits.ts, sin importaciones. El modal de tarjeta lo importa de ahí; validators.ts lo importa y lo reexporta, así que esquemas y acciones no cambian. - Nuevo test/client-imports.test.ts: falla si un componente "use client" importa zod o validators.ts, o si limits.ts gana importaciones. - docs/card-detail.md: la sección de descripción y vencimiento describe useActionState, updateCard(prev, formData): Promise<CardDetailsState>, el maxLength, la normalización de \r\n, la regla de la descripción sin cambios y los mensajes de error. Ya no dice que una fecha inválida se guarda como null. - docs/testing.md y docs/DECISIONES.md al día. POR QUÉ El modal era el único componente de cliente que importaba validators.ts: metía zod en el bundle del tablero solo por un número, y validators.ts usa Buffer, que no existe en el navegador. La documentación seguía con la firma anterior de updateCard. VERIFICADO pnpm lint, pnpm typecheck, pnpm test (el test nuevo falla con la importación anterior) y pnpm test:integration. pnpm build no se ha ejecutado en este cambio.
QUÉ CAMBIA boardKeyboardCoordinates resuelve también izquierda/derecha de una tarjeta: salta a la columna vecina (tomando como referencia el borde de su propia columna) y, dentro de ella, a la tarjeta más cercana en vertical o a la columna si está vacía. POR QUÉ Con sortableKeyboardCoordinates la flecha izquierda elegía la columna propia de la tarjeta, cuyo borde queda 8 px antes, y la tarjeta volvía a su sitio sin moverse. VERIFICADO Tests nuevos en test/board-collision.test.ts (fallan con el código anterior); 224 tests unitarios, lint y typecheck en verde. Playwright: la tarjeta pasa a una columna vacía a la izquierda, vuelve a la derecha y el cambio queda en la base de datos.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Qué cambia
Adaptación de orgtable al estándar de los proyectos: pnpm, seguridad, revisión completa del código y documentación. 118 commits temáticos; el porqué de cada decisión está en
docs/DECISIONES.md(registro nuevo, más recientes arriba).Gestor de paquetes y dependencias
packageManagerfijado,pnpm-lock.yaml,allowBuildssolo para Prisma). Dockerfile con Corepack, CI conpnpm/action-setupy Node 22.next16.3.6, Prisma 7.10, React 19.3…) +overridespara transitivas:pnpm auditpasa de 63 avisos (2 críticos) a 0. Un aviso ignorado y justificado (deepmerge-ts, solo en el CLI de Prisma).Seguridad
proxy.ts,app/lib/csp.ts), HSTS y CSP cerrada para/apiy los.svg.X-Forwarded-For(TRUSTED_PROXY_HOPS) + límite por cuenta; sin enumeración de emails por tiempo; la auditoría ya no guarda el email tecleado.Securesegún el protocolo real: la instalación porhttp://IP:3000vuelve a permitir el login.Interfaz y accesibilidad
<dialog>nativo.es-ES: vencimientos como fecha de calendario (UTC), fechas del servidor enORGTABLE_TIMEZONE(por defectoAtlantic/Canary), horas del cliente en la zona del navegador.Base de datos
20260924000000_position_collate_c: las columnaspositionpasan aCOLLATE "C"para que el orden fractional sea correcto también en Postgres con glibc. Se aplica sola al arrancar el contenedor; la reversión está documentada en el propio SQL.Tiempo real
LISTEN/NOTIFYque reconecta; resincronización del tablero y de la campana tras reconectar.Operación y documentación
backup.sh/restore.sh/update.shfiables (backup verificado, restauración con confirmación, backup antes de actualizar);CRON_SECRETllega al contenedor; imagen con el código de solo lectura.LICENSE), ADR 0005 sustituido por el ADR 0016.pnpm audit.Variables de entorno nuevas
Todas documentadas en
.env.exampley en el README:TRUSTED_PROXY_HOPS(por defecto0;1detrás de Caddy/nginx).ORGTABLE_TIMEZONE(por defectoAtlantic/Canary).CRON_SECRETya existía; ahorainstall.shla genera y el compose la pasa al contenedor.Verificado
pnpm lint,pnpm typecheck, 224 tests unitarios y 212 de integración (PostgreSQL real) en verde;pnpm buildsin avisos;pnpm auditlimpio./api/healthok, cron 401/200 según el secreto, usuario sin privilegios.Pendiente de decidir
SECURITY.mdlleva un TODO).renameBoard/renameList/renameCard/deleteLabelno se usan en la interfaz: conectarlas o borrarlas.AuditLog, ficheros huérfanos): no se limpian aquí.Al desplegar
scripts/update.sh(hace copia antes) odocker compose build && docker compose up -d. AñadirTRUSTED_PROXY_HOPS=1al.envsi hay proxy inverso.