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

  1. El requisito de negocio y decisiones de diseño
  2. El modelo de datos
  3. Autorización por pertenencia: más allá del RBAC
  4. Ordenación manual sin renumerar la lista
  5. Edición concurrente: bloqueo optimista y 412
  6. El reto técnico: actividad y notificaciones
  7. Tablero en vivo con Socket.IO
  8. Vencimientos, filtros e informes
  9. Exportación a CSV con streams
  10. Pruebas de la política de permisos y qué queda fuera

  1. 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, colaborador o responsable.
  • 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.

  1. 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 };

  1. 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).

  1. 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.

  1. 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.

  1. 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.

  1. 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.

  1. 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;

  1. 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.

  1. 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 PUT sin If-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 de actividad. 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

  1. 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.

  2. Reasignación masiva con actividad. Implementa POST /espacios/:id/tareas/reasignar que 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.

  3. 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

Módulo 2: Conceptos Básicos

Módulo 3: Sistema de Archivos y E/S

Módulo 4: HTTP y Servidores Web

Módulo 5: NPM y Gestión de Paquetes

Módulo 6: Framework Express.js

Módulo 7: Bases de Datos y ORMs

Módulo 8: Autenticación y Autorización

Módulo 9: Pruebas y Depuración

Módulo 10: Temas Avanzados

Módulo 11: Despliegue y DevOps

Módulo 12: Proyectos del Mundo Real

© Copyright 2026. Todos los derechos reservados