El cuarto y último proyecto no mira al público: mira hacia dentro. Es la herramienta interna del equipo de Escena Viva, donde se coordina todo lo que ocurre antes de que se levante el telón. "Revisar sonido del Auditorio Ribera", "imprimir entradas del Festival", "confirmar rider del cuarteto", "montar gradas de la Sala Bóveda": tareas reales, con responsable, fecha de vencimiento y dependencias entre ellas.
La idea genuinamente nueva de este proyecto es la colaboración. Hasta ahora cada usuario era dueño de sus cosas: sus entradas, su pedido, sus artículos. Aquí varias personas trabajan sobre los mismos objetos al mismo tiempo, y eso rompe tres cosas de golpe: la autorización deja de resolverse con el rol global, dos ediciones simultáneas pueden pisarse, y hace falta un relato de "qué ha pasado" que nadie pueda alterar.
Contenido
- El requisito de negocio y decisiones de diseño
- El modelo de datos
- Autorización por pertenencia: más allá del RBAC
- Ordenación manual sin renumerar la lista
- Edición concurrente: bloqueo optimista y 412
- El reto técnico: actividad y notificaciones
- Tablero en vivo con Socket.IO
- Vencimientos, filtros e informes
- Exportación a CSV con streams
- Pruebas de la política de permisos y qué queda fuera
- El requisito de negocio y decisiones de diseño
- El trabajo se organiza en espacios: uno por sala (
Teatro Almendra,Sala Bóveda,Auditorio Ribera) y uno por evento grande (evt-003). - Cada espacio tiene miembros con un papel propio:
observador,colaboradororesponsable. - Las tareas tienen estado, prioridad, vencimiento, persona asignada, etiquetas, subtareas y dependencias.
- El orden dentro de una columna lo decide una persona arrastrando, y ese orden es compartido.
- Todo cambio queda registrado en un historial de actividad por espacio.
- Los miembros reciben notificaciones agrupadas: nadie quiere 40 correos por una tarde de reorganización.
- El tablero se actualiza en vivo para quien lo tenga abierto.
| Decisión | Alternativa descartada | Por qué |
|---|---|---|
| PostgreSQL con Sequelize (M7) | MongoDB | Relaciones densas (espacio-miembro-tarea-dependencia), filtros combinados e informes con agregación SQL. Y la pertenencia se comprueba mejor con un JOIN |
| Permiso por pertenencia al espacio | Solo el rol global del M8 | Un organizador del Teatro Almendra no debe ver el tablero del Auditorio Ribera; el rol global no lo sabe |
| Comprobación empujada a la consulta | Cargar la tarea y luego decidir | Filtrar en la consulta evita la referencia directa insegura y una consulta de más |
| Posiciones fraccionarias | Enteros contiguos renumerados | Mover una tarea no debe reescribir 300 filas |
Bloqueo optimista con version e If-Match |
Bloqueo pesimista al abrir la tarea | El conflicto es raro; bloquear al abrir castiga el caso común y deja bloqueos huérfanos |
| Actividad inmutable como fuente de verdad | Reconstruir el historial de los campos actualizados | Un updated_at no cuenta qué cambió ni quién lo cambió |
| Notificaciones agrupadas y diferidas | Un correo por evento | Diez cambios en un minuto son un correo, no diez |
Dependencias nuevas: ninguna obligatoria. sequelize, pg, bullmq, ioredis, zod, socket.io (del 12-01) y pino ya están. Solo si se opta por LexoRank en vez de posiciones fraccionarias conviene añadir una librería de rangos léxicos.
- El modelo de datos
| Tabla | Campos clave |
|---|---|
espacios |
id, nombre, tipo (sala|evento), referencia (teatro-almendra, evt-003) |
miembros |
espacioId, usuarioId, papel, unidoEn — clave primaria compuesta |
tareas |
id, espacioId, titulo, descripcion, estado, prioridad, vence, asignadoA, etiquetas, tareaPadreId, posicion, version, creadoPor |
dependencias |
tareaId, dependeDeId — clave compuesta, sin ciclos (comprobado en el dominio) |
actividad |
id, espacioId, tareaId, actorId, tipo, datos (JSONB), ocurridoEn — solo INSERT |
Estados: pendiente, en_curso, bloqueada, hecha. Pasar a en_curso exige que todas las dependencias estén hecha; si no, la tarea es bloqueada. Esa regla vive en el dominio:
// src/dominio/tarea.js
const puedeEmpezar = (dependencias) => dependencias.every((d) => d.estado === 'hecha');
function cambiarEstado(tarea, estadoNuevo, dependencias) {
if (estadoNuevo === 'en_curso' && !puedeEmpezar(dependencias)) throw new ConflictoDeEstado(
'DEPENDENCIAS_PENDIENTES',
{ pendientes: dependencias.filter((d) => d.estado !== 'hecha').map((d) => d.id) });
return { ...tarea, estado: estadoNuevo, version: tarea.version + 1 };
}
module.exports = { puedeEmpezar, cambiarEstado };
- Autorización por pertenencia: más allá del RBAC
En el M8 construiste exigirRol('organizador') y una política pura en src/autorizacion/politica.js, que resolvía preguntas como "¿puede este usuario crear eventos?". Aquí la pregunta es otra: "¿puede este usuario mover esta tarea de este espacio?". El rol global no contiene la respuesta porque depende de una relación que vive en la base de datos.
La solución no es meter consultas dentro de la política —dejaría de ser pura y probable sin base de datos—, sino cargar el contexto antes y decidir después.
// src/autorizacion/politica-espacios.js — sigue siendo PURA: recibe lo que necesita
const VER = ['espacio:ver', 'tarea:ver', 'comentario:ver'];
const EDITAR = [...VER, 'tarea:crear', 'tarea:editar', 'tarea:mover', 'comentario:crear'];
const PERMISOS_POR_PAPEL = { observador: VER, colaborador: EDITAR, responsable: [...EDITAR,
'tarea:borrar', 'miembro:invitar', 'miembro:expulsar', 'espacio:archivar'] };
function puede({ actor, pertenencia, accion, recurso = null }) {
// El administrador global mantiene su llave maestra (M8), auditada aparte.
if (actor.rol === 'administrador') return true;
if (!pertenencia) return false;
if (!(PERMISOS_POR_PAPEL[pertenencia.papel] ?? []).includes(accion)) return false;
// Regla extra por recurso: un colaborador solo borra sus propios comentarios.
if (accion === 'comentario:borrar' && pertenencia.papel === 'colaborador') {
return recurso?.autorId === actor.id;
}
return true;
}
module.exports = { puede, PERMISOS_POR_PAPEL };Un middleware carga la pertenencia una vez por petición y la deja en peticion.pertenencia sin cortar nada —decide la política, no el middleware—. Otro, exigirPermiso(accion), llama a puede(...) y, si dice que no, elige el error: ACCESO_DENEGADO (403) si el usuario sí es miembro, y ESPACIO_NO_ENCONTRADO (404) si no lo es. Ese matiz importa: un 403 confirma que el espacio existe, y eso ya filtra información sobre la organización.
Y la parte importante: la comprobación se empuja a la consulta. En el M8 viste la referencia directa insegura: pedir /tareas/tar-8814 y recibirla porque nadie comprobó que fuera tuya. Con espacios, la tentación es cargar la tarea, mirar su espacioId y comparar. Funciona, pero es frágil —hay que acordarse en cada controlador— y hace dos viajes. Mejor: la pertenencia forma parte del WHERE.
// src/repositorios/tareas-sql.js
async function obtenerTareaVisible({ tareaId, usuarioId }) {
const [tarea] = await sequelize.query(
`SELECT t.* FROM tareas t JOIN miembros m ON m.espacio_id = t.espacio_id
WHERE t.id = :tareaId AND m.usuario_id = :usuarioId`,
{ replacements: { tareaId, usuarioId }, type: SELECT });
return tarea ? aDominio(tarea) : null; // null = no existe O no puede verla
}Un repositorio que no tiene forma de devolver una tarea ajena es mucho más seguro que uno que la devuelve y confía en que alguien compruebe después: la seguridad que depende de recordar algo, tarde o temprano falla. Los listados siguen la misma regla, con WHERE t.espacio_id IN (SELECT espacio_id FROM miembros WHERE usuario_id = :usuarioId).
- Ordenación manual sin renumerar la lista
Marc arrastra "revisar sonido del Auditorio Ribera" desde el final de la columna a la segunda posición. Con posiciones enteras contiguas (1, 2, 3…), insertar en la 2 obliga a sumar uno a todas las siguientes: 300 filas actualizadas por arrastre y conflictos garantizados si dos personas arrastran a la vez.
Posiciones fraccionarias. La posición es un número real y la tarea se coloca en el punto medio entre sus vecinas:
// src/dominio/ordenacion.js — insertar afecta a UNA fila
const PASO_INICIAL = 65536;
function calcularPosicion({ anterior, siguiente }) {
if (!anterior && !siguiente) return PASO_INICIAL;
if (!anterior) return siguiente.posicion / 2; // al principio
if (!siguiente) return anterior.posicion + PASO_INICIAL; // al final
return (anterior.posicion + siguiente.posicion) / 2; // en medio
}
module.exports = { calcularPosicion, PASO_INICIAL };El problema conocido de las fracciones es la precisión: double tiene 53 bits de mantisa, así que tras unas 50 inserciones en el mismo hueco dos posiciones se vuelven indistinguibles. Es raro pero real, y se resuelve detectándolo y recompactando esa lista —operación cara y muy poco frecuente—:
async function moverTarea({ tareaId, anteriorId, siguienteId, espacioId }) {
const [anterior, siguiente] = await repositorioTareas.obtenerVecinas(anteriorId, siguienteId);
if (anterior && siguiente && Math.abs(siguiente.posicion - anterior.posicion) < 1e-9) {
// Se agotó el espacio entre vecinas: renumeramos la columna y reintentamos.
await repositorioTareas.recompactarColumna({ espacioId, estado: anterior.estado });
return moverTarea({ tareaId, anteriorId, siguienteId, espacioId });
}
return repositorioTareas.actualizarPosicion({ tareaId,
posicion: calcularPosicion({ anterior, siguiente }) });
}La alternativa: LexoRank. En vez de números, cadenas ordenables lexicográficamente (0|hzzzzz, 0|i00000…). Entre "a" y "b" siempre cabe "an", y entre "an" y "b" cabe "anv": nunca se agota el espacio, solo crece la longitud. Es lo que usa Jira; a cambio, la generación es más elaborada y el orden depende de la colación de la base de datos.
| Criterio | Fracciones | LexoRank |
|---|---|---|
| Simplicidad | Cuatro líneas | Librería o algoritmo propio |
| Límite | Precisión del double (~50 inserciones en el mismo punto) |
Ninguno práctico; crece la cadena |
| Índice | NUMERIC/DOUBLE, orden natural |
TEXT con colación binaria obligatoria |
Para Escena Viva, con columnas de decenas de tareas, las fracciones son la decisión correcta: más simples, suficientes y con una salida clara si algún día dejan de serlo. Es una decisión pequeña y muy instructiva porque muestra el patrón completo: elige lo simple, conoce su límite, ten preparado el plan B.
- Edición concurrente: bloqueo optimista y 412
Lucía y Marc abren la misma tarea. Lucía cambia la prioridad a alta; Marc, dos segundos después, cambia el responsable y guarda con los datos que cargó antes del cambio de Lucía. Si aceptamos su PUT completo, la prioridad vuelve a media y nadie se entera: es la actualización perdida, el bug más silencioso de las aplicaciones colaborativas.
La respuesta es el bloqueo optimista del M10 —If-Match y 412— aplicado al campo version:
// src/controladores/tareas.js — bloqueo optimista con If-Match (M10)
async function actualizarTarea(peticion, respuesta, siguiente) {
const versionEsperada = peticion.get('If-Match');
// Exigir la precondición evita el "olvido" del cliente, que reintroduce el bug.
if (!versionEsperada) return siguiente(new ErrorDeAplicacion('PRECONDICION_REQUERIDA', 428));
const actualizada = await repositorioTareas.actualizarSiVersion({
tareaId: peticion.params.tareaId, actorId: peticion.usuario.id,
version: Number(versionEsperada.replaceAll('"', '')),
cambios: peticion.datosValidados }); // zod del M6
if (!actualizada) {
// Devolvemos el estado actual: el cliente puede mostrar la diferencia sin
// una segunda petición y sin perder lo que el usuario tenía escrito.
const actual = await repositorioTareas.obtenerTareaVisible(
{ tareaId: peticion.params.tareaId, usuarioId: peticion.usuario.id });
return respuesta.status(412).json({ error: { codigo: 'CONFLICTO_DE_VERSION', estado: 412,
mensaje: 'Otra persona modifico la tarea mientras la editabas',
detalles: { versionActual: actual.version, tareaActual: actual } } });
}
respuesta.set('ETag', `"${actualizada.version}"`);
return respuesta.json(actualizada);
}En el repositorio, la condición viaja dentro del UPDATE: UPDATE tareas SET … version = version + 1 WHERE id = :tareaId AND version = :version RETURNING *. Si otra persona se adelantó, el filtro no encaja, no se actualiza ninguna fila y RETURNING devuelve el conjunto vacío que traducimos a null. Es atómico y no necesita transacción explícita: la misma idea del findOneAndUpdate condicional del 12-01, ahora en SQL.
Qué debe hacer el cliente ante un 412. No recargar en silencio (perdería lo escrito) ni forzar el guardado (volveríamos al principio). Lo correcto: mostrar qué cambió la otra persona, conservar lo que el usuario tenía escrito y ofrecer "conservar lo mío" o "quedarme con lo suyo". Y una mejora barata: si los campos tocados no se solapan (Lucía tocó prioridad, Marc responsable), se puede fusionar automáticamente y avisar. Enviar PATCH con solo los campos modificados, en vez de PUT con la tarea entera, reduce muchísimo la frecuencia de conflictos reales.
- El reto técnico: actividad y notificaciones
La actividad es la fuente de verdad de "qué ha pasado". No es un registro decorativo: es una tabla de solo inserción donde cada cambio del dominio deja un hecho con actor, momento y datos:
// src/servicios/actividad.js
const TIPOS = { TAREA_CREADA: 'tarea.creada', TAREA_ASIGNADA: 'tarea.asignada',
TAREA_ESTADO_CAMBIADO: 'tarea.estado_cambiado', TAREA_MOVIDA: 'tarea.movida',
COMENTARIO_CREADO: 'comentario.creado', MIEMBRO_INVITADO: 'miembro.invitado' };
// Se escribe DENTRO de la misma transacción que el cambio: si el cambio se
// deshace, la actividad también. Nunca hay un hecho sin su efecto. En `datos`
// va el detalle, p. ej. { de: 'pendiente', a: 'en_curso' }.
const registrarActividad = ({ transaccion, espacioId, tareaId, actorId, tipo, datos }) =>
repositorioActividad.insertar({ espacioId, tareaId, actorId, tipo, datos,
ocurridoEn: new Date().toISOString() }, transaccion);Esta tabla se parece mucho al abastecimiento de eventos (event sourcing), donde el estado se reconstruye reproduciendo los hechos. Aquí no llegamos tan lejos: mantenemos el estado actual en tareas y la actividad es un registro paralelo. Es un compromiso deliberado —las consultas siguen siendo triviales— y conviene saber que existe la versión pura por si algún día el requisito es "reconstruye el tablero tal como estaba el martes". Regla de la tabla: nunca se actualiza ni se borra; si algo se registró mal, se registra una corrección. Un historial editable no es un historial.
Notificaciones: el problema de los 40 correos. Marc reorganiza el tablero del Festival: mueve 12 tareas, reasigna 5 y comenta en 3. Si cada hecho dispara un correo, Lucía recibe 20 correos en cuatro minutos y crea un filtro para no volver a ver ninguno: la notificación deja de funcionar. La solución es agrupar y diferir con BullMQ (M10) usando una ventana de silencio —el primer hecho encola un trabajo con retraso y los siguientes se acumulan sin encolar nada nuevo—.
// src/servicios/notificaciones.js
const VENTANA_AGRUPACION_MS = 5 * 60 * 1000;
async function encolarNotificacion({ destinatarioId, actividad, redis, colaNotificaciones }) {
const preferencias = await repositorioPreferencias.obtener(destinatarioId);
if (!preferencias.recibe(actividad.tipo)) return; // el usuario mandó silencio
if (actividad.actorId === destinatarioId) return; // nadie se notifica a sí mismo
const clave = `pendientes:${destinatarioId}`;
await redis.rpush(clave, JSON.stringify(actividad)); // cada hecho se acumula
await redis.expire(clave, 3600); // red de seguridad si el consumidor cae
// jobId determinista + delay: si el trabajo ya existe, no se duplica. El primer
// hecho abre la ventana; los demás se suman al mismo envío.
await colaNotificaciones.add('resumen-actividad', { destinatarioId },
{ delay: VENTANA_AGRUPACION_MS, jobId: `resumen-${destinatarioId}` });
}
// src/colas/consumidor-notificaciones.js — proceso aparte (M10/M11)
new Worker('notificaciones', async ({ data }) => {
const clave = `pendientes:${data.destinatarioId}`;
// Leer y vaciar en una sola operación atómica: si llegan hechos nuevos
// mientras enviamos, abren una ventana nueva en vez de perderse.
const crudos = await redis.multi().lrange(clave, 0, -1).del(clave).exec();
const hechos = (crudos[0][1] ?? []).map((h) => JSON.parse(h));
if (hechos.length === 0) return { enviados: 0 };
const destinatario = await repositorioUsuarios.obtener(data.destinatarioId);
await servicioCorreo.enviar({
para: destinatario.correo, // [email protected]
asunto: hechos.length === 1 ? resumirHecho(hechos[0])
: `${hechos.length} novedades en tus espacios de Escena Viva`,
cuerpo: plantillaResumen({ hechos: agruparPorEspacio(hechos) }) });
return { enviados: 1, hechos: hechos.length };
}, { connection, concurrency: 5 });Las preferencias por usuario son parte del diseño, no un extra: canal (correo, en la aplicación, ambos), tipos de hecho que interesan y ventana de agrupación (inmediata, 5 minutos, resumen diario). Un sistema de notificaciones sin preferencias acaba silenciado por completo.
- Tablero en vivo con Socket.IO
Aquí los proyectos se conectan: la infraestructura del 12-01 —Socket.IO sobre el http.Server, autenticación en el handshake, adaptador de Redis para varios procesos— se reutiliza tal cual, cambiando solo el espacio de nombres (/tablero/v1) y las salas (espacio:<id>). El manejador de espacio:suscribir aplica la misma regla del punto 3: consulta buscarPertenencia, devuelve ESPACIO_NO_ENCONTRADO si no la hay y solo entonces hace socket.join. La pertenencia decide también aquí, y se consulta: nunca se confía en el espacioId que envía el cliente.
Y la emisión no se dispara desde el controlador a mano, sino desde el mismo sitio que ya registra la actividad, un único punto de verdad: el servicio publicarActividad emite actividad:nueva a la sala espacio:<id> de /tablero/v1 —para quien está mirando el tablero ahora mismo— y recorre repositorioEspacios.miembrosInteresados(actividad) llamando a encolarNotificacion para quien no está mirando.
Ojo con un detalle del M10: el io vive en el proceso web y las notificaciones se procesan en el consumidor, así que el adaptador de Redis del 12-01 es lo que permite que una emisión hecha desde cualquier proceso llegue a todos los sockets.
- Vencimientos, filtros e informes
Dos trabajos programados de BullMQ resuelven los vencimientos: uno repetible diario (repeat: { pattern: '0 8 * * *', tz: 'Europe/Madrid' }) que avisa de lo que vence mañana, y uno diferido por tarea que recuerda 24 horas antes de su fecha, con jobId: recordatorio-<tareaId> para que reprogramar sustituya en vez de acumular recordatorios zombis. Como en el 12-03, el consumidor revalida antes de actuar: si la tarea ya está hecha o la fecha cambió, el trabajo se descarta; un trabajo diferido siempre puede ejecutarse en un mundo distinto al que lo creó. El filtro típico del equipo, además, es combinado —"tareas del Auditorio Ribera, en curso o bloqueadas, asignadas a Marc, que vencen esta semana"— y sin el índice adecuado eso es un recorrido secuencial de la tabla.
-- El orden sigue el uso: espacio_id primero porque SIEMPRE está presente (punto 3).
CREATE INDEX idx_tareas_espacio_estado_vence ON tareas (espacio_id, estado, vence);
-- Índice PARCIAL: solo lo abierto, que es lo que se consulta a diario.
CREATE INDEX idx_tareas_asignado_abiertas ON tareas (asignado_a, vence)
WHERE estado <> 'hecha';
CREATE INDEX idx_tareas_etiquetas ON tareas USING GIN (etiquetas);El índice parcial es una de esas herramientas que pasan desapercibidas: la mayoría de tareas de un espacio veterano están hecha, así que un índice que las excluye es mucho más pequeño y más rápido, y sirve justo a la consulta que se hace cien veces al día. Compruébalo siempre con EXPLAIN ANALYZE (M7): un índice que el planificador no usa es solo coste de escritura. Los informes del espacio son agregación pura:
SELECT estado, COUNT(*) AS total, COUNT(*) FILTER (WHERE vence < NOW()) AS vencidas,
AVG(EXTRACT(EPOCH FROM (actualizado_en - creado_en)) / 3600)
FILTER (WHERE estado = 'hecha') AS horas_medias
FROM tareas WHERE espacio_id = :espacioId GROUP BY estado;
- Exportación a CSV con streams
"Exporta todas las tareas del espacio a CSV" parece trivial hasta que el espacio tiene 200.000 filas históricas. Cargarlas en un array y devolver un respuesta.send gigante consume cientos de megas y bloquea el proceso mientras serializa. Es exactamente el problema que resolvió el M3: fluir, no acumular.
// src/controladores/exportacion.js
const { pipeline } = require('node:stream/promises');
const { Transform } = require('node:stream');
async function exportarTareasCsv(peticion, respuesta) {
const { espacioId } = peticion.params;
respuesta.setHeader('Content-Type', 'text/csv; charset=utf-8');
respuesta.setHeader('Content-Disposition', `attachment; filename="tareas-${espacioId}.csv"`);
// Flujo del cursor de la base de datos: las filas llegan de una en una.
const flujoFilas = repositorioTareas.flujoDeTareas({ espacioId });
const aCsv = new Transform({
objectMode: true,
construct(fin) { this.push('id,titulo,estado,prioridad,vence,asignado_a\n'); fin(); },
transform(tarea, codificacion, fin) {
this.push([tarea.id, escaparCampoCsv(tarea.titulo), tarea.estado, tarea.prioridad,
tarea.vence ? new Date(tarea.vence).toISOString() : '',
tarea.asignadoA ?? ''].join(',') + '\n');
fin();
} });
try {
// pipeline (M3) propaga errores y destruye los flujos si el cliente aborta,
// de modo que el cursor no queda abierto consumiendo una conexión del pool.
await pipeline(flujoFilas, aCsv, respuesta);
} catch (error) {
// La respuesta ya empezó: no se puede enviar JSON de error, solo cortar.
peticion.registrador.error({ error, espacioId }, 'fallo exportando CSV');
respuesta.destroy();
}
}
// Un titulo con coma, comilla o salto de linea rompe el CSV si no se escapa.
const escaparCampoCsv = (valor) => /[",\n\r]/.test(String(valor ?? ''))
? `"${String(valor).replaceAll('"', '""')}"` : String(valor ?? '');El uso de memoria es constante —unos pocos megas— sea cual sea el volumen.
- Pruebas de la política de permisos y qué queda fuera
Con permisos que dependen de una relación, las pruebas de autorización dejan de ser opcionales. La política es pura: se prueba en milisegundos y sin base de datos.
describe('politica de espacios', () => {
const marc = { id: 'usr-marc', rol: 'organizador' };
it('un observador no puede crear tareas', () => {
expect(puede({ actor: marc, pertenencia: { papel: 'observador' }, accion: 'tarea:crear' }))
.to.equal(false);
});
it('sin pertenencia no puede nada, aunque su rol global sea organizador', () => {
expect(puede({ actor: marc, pertenencia: null, accion: 'tarea:ver' })).to.equal(false);
});
it('el administrador global si puede', () => {
expect(puede({ actor: { id: 'usr-admin', rol: 'administrador' },
pertenencia: null, accion: 'espacio:archivar' })).to.equal(true);
});
});
describe('acceso por pertenencia', () => { // integracion con supertest (M9)
it('devuelve 404 al pedir una tarea de un espacio ajeno', async () => {
const tarea = await crearTarea({ espacioId: 'esp-ribera', titulo: 'Revisar sonido' });
await peticion(aplicacion).get(`/api/v1/tareas/${tarea.id}`)
.set('Authorization', `Bearer ${tokenDe('usr-lucia')}`).expect(404);
});
it('no lista tareas de espacios a los que no pertenece', async () => {
const { body } = await peticion(aplicacion).get('/api/v1/tareas?vence=semana')
.set('Authorization', `Bearer ${tokenDe('usr-lucia')}`).expect(200);
expect(body.tareas.every((t) => t.espacioId === 'esp-almendra')).to.equal(true);
});
});Falta la del punto 5: un PATCH con If-Match: "2" sobre una tarea en versión 3 debe devolver 412. Y la última de las escritas es la que más veces salva de una filtración real: no comprueba un permiso concreto, comprueba que el listado no se cuela. Añádela para cada endpoint de listado que exista.
| Fuera | Por qué | Ampliación |
|---|---|---|
| Plantillas de espacio ("montaje estándar de sala") | Es una capa sobre lo construido | Clonar un espacio con sus tareas y dependencias relativas |
| Diagrama de Gantt y ruta crítica | Cálculo y front-end considerables | Grafo de dependencias + orden topológico en el servidor |
| Campos personalizados por espacio | Modelo y validación dinámicos | Columna JSONB + esquema zod generado por espacio |
| Adjuntos e integraciones externas | Ya resuelto en el 12-03; cada integración es un proyecto | Reutilizar el pipeline de subida; webhooks salientes firmados |
Errores Comunes y Consejos
- Comprobar la pertenencia en el controlador después de cargar el recurso. Funciona hasta que alguien añade un endpoint y se olvida. Empuja el filtro a la consulta del repositorio.
- Devolver 403 cuando el usuario no es miembro. Un 403 confirma que el espacio existe; para recursos privados, 404. Y no aceptes
PUTsinIf-Match: es abrir la puerta a la actualización perdida, así que devuelve 428 si falta la precondición. - Renumerar la lista entera al arrastrar. Posiciones fraccionarias, con recompactación como excepción. Y no actualices ni borres filas de
actividad: deja de ser fuente de verdad en el momento en que es editable. - Notificar cada hecho por separado. El usuario silencia el canal y pierdes el único medio de avisarle.
- Consejo: incluye el
idPeticion(M11) en cada fila deactividad. Cuando alguien pregunte "¿por qué esta tarea cambió de responsable?", podrás cruzar el hecho con los registros de pino de esa petición exacta.
Ejercicios
-
Dependencias sin ciclos. Implementa
crearDependencia(tareaId, dependeDeId)que rechace con 409 cualquier dependencia que cree un ciclo (A → B → C → A). Escribe pruebas para el ciclo directo y el indirecto. -
Reasignación masiva con actividad. Implementa
POST /espacios/:id/tareas/reasignarque cambie el responsable de N tareas en una transacción, registre un hecho por tarea y produzca un solo correo agrupado. Prueba que el destinatario recibe un envío y no N. -
Vista "mi día". Devuelve las tareas del usuario que vencen hoy o están vencidas, de todos sus espacios, ordenadas por prioridad y vencimiento, en una sola consulta que use el índice parcial.
Soluciones
1. Dependencias sin ciclos
// src/servicios/dependencias.js
async function crearDependencia({ tareaId, dependeDeId, actorId }) {
if (tareaId === dependeDeId) throw new ConflictoDeEstado('DEPENDENCIA_CIRCULAR', { tareaId });
// Recorrido en anchura: si desde dependeDeId se puede llegar a tareaId,
// añadir esta arista cerraría el ciclo.
const visitados = new Set(); const cola = [dependeDeId];
while (cola.length > 0) {
const actual = cola.shift();
if (actual === tareaId) throw new ConflictoDeEstado('DEPENDENCIA_CIRCULAR',
{ tareaId, dependeDeId });
if (visitados.has(actual)) continue;
visitados.add(actual);
cola.push(...(await repositorioTareas.dependenciasDe(actual)).map((p) => p.dependeDeId));
}
return repositorioTareas.insertarDependencia({ tareaId, dependeDeId, actorId });
}La prueba encadena B→A y C→B y comprueba que A→C se rechaza con DEPENDENCIA_CIRCULAR. Con grafos grandes el recorrido por consultas encadenadas se paga caro: la alternativa es un WITH RECURSIVE que resuelve el alcance en un solo viaje.
2. Reasignación masiva agrupada
async function reasignarTareas({ espacioId, tareaIds, nuevoResponsableId, actor }) {
const hechos = await sequelize.transaction(async (t) => {
const acumulados = [];
for (const tareaId of tareaIds) {
const tarea = await repositorioTareas.obtenerParaActualizar(tareaId, t);
if (tarea?.espacioId !== espacioId) throw new RecursoNoEncontrado(
'TAREA_NO_ENCONTRADA', { tareaId });
await repositorioTareas.actualizarEnTransaccion(
{ tareaId, cambios: { asignadoA: nuevoResponsableId } }, t);
acumulados.push(await registrarActividad({ transaccion: t, espacioId, tareaId,
actorId: actor.id, tipo: TIPOS.TAREA_ASIGNADA,
datos: { de: tarea.asignadoA, a: nuevoResponsableId } }));
}
return acumulados;
});
// Fuera de la transacción: efectos externos solo tras confirmar el commit.
for (const h of hechos) await publicarActividad({ actividad: h, io, colaNotificaciones, redis });
return hechos.length;
}El agrupamiento sale gratis: los N hechos se acumulan en la lista de Redis y comparten el mismo jobId resumen-<destinatarioId>, así que BullMQ mantiene un trabajo diferido. La prueba espía servicioCorreo.enviar, adelanta el reloj VENTANA_AGRUPACION_MS y verifica callCount === 1 con un asunto que incluye "12 novedades".
3. Vista "mi día"
async function tareasDeMiDia({ usuarioId }) {
const [filas] = await sequelize.query(
`SELECT t.*, e.nombre AS espacio_nombre
FROM tareas t JOIN espacios e ON e.id = t.espacio_id
WHERE t.asignado_a = :usuarioId AND t.estado <> 'hecha'
AND t.vence < (CURRENT_DATE + INTERVAL '1 day')
AND EXISTS (SELECT 1 FROM miembros m
WHERE m.espacio_id = t.espacio_id AND m.usuario_id = :usuarioId)
ORDER BY CASE t.prioridad WHEN 'alta' THEN 0 WHEN 'media' THEN 1 ELSE 2 END, t.vence
LIMIT 100`, { replacements: { usuarioId } });
return filas.map(aDominio);
}El EXISTS sobre miembros parece redundante —la tarea ya está asignada al usuario— pero cubre el caso de alguien expulsado del espacio que conserva tareas asignadas: sin él seguiría viendo trabajo de un espacio al que ya no pertenece. El WHERE estado <> 'hecha' encaja con el índice parcial del punto 8; confírmalo con EXPLAIN ANALYZE.
Conclusión
La herramienta interna cierra los cuatro proyectos con el reto que solo aparece cuando varias personas comparten los mismos objetos. Has visto que el RBAC del Módulo 8 no muere, se extiende: la política sigue siendo una función pura, pero recibe la pertenencia además del rol, y —la parte que de verdad protege— la comprobación se empuja al WHERE del repositorio, para que devolver un recurso ajeno sea imposible en lugar de improbable.
Has resuelto la actualización perdida con bloqueo optimista y una respuesta 412 que le da al cliente todo lo necesario para reconciliar; has ordenado listas sin reescribirlas con posiciones fraccionarias, conociendo su límite y su plan B; y has construido un registro de actividad inmutable que sirve a la vez de historial, de disparador del tiempo real del 12-01 y de origen de unas notificaciones agrupadas. Con esto quedan construidos los cuatro productos satélite de Escena Viva. La última lección del curso no añade otro proyecto: recoge el viaje entero, convierte lo aprendido en una lista de comprobación de puesta en producción y en un marco para tomar decisiones técnicas, y traza con honestidad hacia dónde seguir cuando el curso termine.
Curso de Node.js: De Principiante a Avanzado
Módulo 1: Introducción a Node.js
- ¿Qué es Node.js?
- Instalación y Configuración del Entorno
- Tu Primer Programa en Node.js
- El REPL de Node.js
- JavaScript Moderno para Node.js
- El Proyecto del Curso: la Plataforma Escena Viva
Módulo 2: Conceptos Básicos
- Arquitectura de Node.js
- El Bucle de Eventos (Event Loop)
- Callbacks y Programación Asíncrona
- Promesas y async/await
- Eventos y EventEmitter
- Módulos CommonJS y require()
- Módulos ES e Interoperabilidad
Módulo 3: Sistema de Archivos y E/S
- Lectura y Escritura de Archivos
- El Módulo fs a Fondo
- Rutas Multiplataforma con el Módulo path
- Trabajando con Streams
- Streams de Transformación y pipeline
- Buffers y Datos Binarios
Módulo 4: HTTP y Servidores Web
- Creando un Servidor HTTP Simple
- Manejo de Solicitudes y Respuestas
- Enrutamiento Manual
- Sirviendo Archivos Estáticos
- Recibiendo Datos: Cuerpos de Petición y JSON
- Consumiendo APIs Externas desde Node.js
Módulo 5: NPM y Gestión de Paquetes
- Introducción a NPM y package.json
- Instalación y Uso de Paquetes
- Versionado Semántico y package-lock
- Scripts de npm y Automatización del Proyecto
- Creación y Publicación de Paquetes
- Seguridad y Mantenimiento de Dependencias
Módulo 6: Framework Express.js
- Introducción a Express.js
- Configuración de una Aplicación Express
- Enrutamiento en Express
- Middleware
- Middleware de Terceros Esenciales
- Validación de Datos de Entrada
- Manejo de Errores
Módulo 7: Bases de Datos y ORMs
- Introducción a las Bases de Datos
- Usando MongoDB con Mongoose
- Operaciones CRUD
- Relaciones, Poblado y Consultas Avanzadas
- Usando Bases de Datos SQL con Sequelize
- Migraciones, Transacciones y Datos de Prueba
Módulo 8: Autenticación y Autorización
- Introducción a la Autenticación
- Registro de Usuarios y Hash de Contraseñas
- Sesiones y Cookies con Passport.js
- Autenticación con JWT
- Control de Acceso Basado en Roles
- Buenas Prácticas de Seguridad en APIs
Módulo 9: Pruebas y Depuración
- Introducción a las Pruebas
- Pruebas Unitarias con Mocha y Chai
- Dobles de Prueba con Sinon
- Pruebas de Integración
- Cobertura y Automatización de las Pruebas
- Depuración de Aplicaciones Node.js
Módulo 10: Temas Avanzados
- El Módulo Cluster
- Hilos de Trabajo (Worker Threads)
- Caché y Colas de Trabajo con Redis
- Optimización del Rendimiento
- Construcción de APIs RESTful
- GraphQL con Node.js
Módulo 11: Despliegue y DevOps
- Configuración y Variables de Entorno
- Registro y Monitorización en Producción
- Usando PM2 para la Gestión de Procesos
- Empaquetado con Docker
- Desplegando en Heroku y Otras PaaS
- Integración y Despliegue Continuos
