El quart i últim projecte no mira al públic: mira cap a dins. És l'eina interna de l'equip d'Escena Viva, on es coordina tot el que passa abans que s'aixequi el teló. «Revisar el so de l'Auditorio Ribera», «imprimir entrades del Festival», «confirmar el rider del quartet», «muntar les grades de la Sala Bóveda»: tasques reals, amb responsable, data de venciment i dependències entre elles.
La idea genuïnament nova d'aquest projecte és la col·laboració. Fins ara cada usuari era amo de les seves coses: les seves entrades, la seva comanda, els seus articles. Aquí diverses persones treballen sobre els mateixos objectes al mateix temps, i això trenca tres coses de cop: l'autorització deixa de resoldre's amb el rol global, dues edicions simultànies es poden trepitjar, i cal un relat de «què ha passat» que ningú no pugui alterar.
Contingut
- El requisit de negoci i decisions de disseny
- El model de dades
- Autorització per pertinença: més enllà del RBAC
- Ordenació manual sense renumerar la llista
- Edició concurrent: blocatge optimista i 412
- El repte tècnic: activitat i notificacions
- Tauler en viu amb Socket.IO
- Venciments, filtres i informes
- Exportació a CSV amb streams
- Proves de la política de permisos i què queda fora
- El requisit de negoci i decisions de disseny
- La feina s'organitza en espais: un per sala (
Teatro Almendra,Sala Bóveda,Auditorio Ribera) i un per esdeveniment gran (evt-003). - Cada espai té membres amb un paper propi:
observador,collaboradororesponsable. - Les tasques tenen estat, prioritat, venciment, persona assignada, etiquetes, subtasques i dependències.
- L'ordre dins d'una columna el decideix una persona arrossegant, i aquest ordre és compartit.
- Tot canvi queda registrat en un historial d'activitat per espai.
- Els membres reben notificacions agrupades: ningú no vol 40 correus per una tarda de reorganització.
- El tauler s'actualitza en viu per a qui el tingui obert.
| Decisió | Alternativa descartada | Per què |
|---|---|---|
| PostgreSQL amb Sequelize (M7) | MongoDB | Relacions denses (espai-membre-tasca-dependència), filtres combinats i informes amb agregació SQL. I la pertinença es comprova millor amb un JOIN |
| Permís per pertinença a l'espai | Només el rol global del M8 | Un organitzador del Teatro Almendra no ha de veure el tauler de l'Auditorio Ribera; el rol global no ho sap |
| Comprovació empesa a la consulta | Carregar la tasca i després decidir | Filtrar a la consulta evita la referència directa insegura i una consulta de més |
| Posicions fraccionàries | Enters contigus renumerats | Moure una tasca no ha de reescriure 300 files |
Blocatge optimista amb versio i If-Match |
Blocatge pessimista en obrir la tasca | El conflicte és rar; blocar en obrir castiga el cas comú i deixa blocatges orfes |
| Activitat immutable com a font de veritat | Reconstruir l'historial dels camps actualitzats | Un updated_at no explica què va canviar ni qui ho va canviar |
| Notificacions agrupades i diferides | Un correu per esdeveniment | Deu canvis en un minut són un correu, no deu |
Dependències noves: cap d'obligatòria. sequelize, pg, bullmq, ioredis, zod, socket.io (del 12-01) i pino ja hi són. Només si s'opta per LexoRank en comptes de posicions fraccionàries convé afegir-hi una llibreria de rangs lèxics.
- El model de dades
| Taula | Camps clau |
|---|---|
espais |
id, nom, tipus (sala|esdeveniment), referencia (teatro-almendra, evt-003) |
membres |
espaiId, usuariId, paper, unitEl — clau primària composta |
tasques |
id, espaiId, titol, descripcio, estat, prioritat, venciment, assignatA, etiquetes, tascaPareId, posicio, versio, creatPer |
dependencies |
tascaId, depenDeId — clau composta, sense cicles (comprovat al domini) |
activitat |
id, espaiId, tascaId, actorId, tipus, dades (JSONB), ocorregutEl — només INSERT |
Estats: pendent, en_curs, bloquejada, feta. Passar a en_curs exigeix que totes les dependències estiguin feta; si no, la tasca és bloquejada. Aquesta regla viu al domini:
// src/domini/tasca.js
const potComencar = (dependencies) => dependencies.every((d) => d.estat === 'feta');
function canviarEstat(tasca, estatNou, dependencies) {
if (estatNou === 'en_curs' && !potComencar(dependencies)) throw new ConflicteDEstat(
'DEPENDENCIES_PENDENTS',
{ pendents: dependencies.filter((d) => d.estat !== 'feta').map((d) => d.id) });
return { ...tasca, estat: estatNou, versio: tasca.versio + 1 };
}
module.exports = { potComencar, canviarEstat };
- Autorització per pertinença: més enllà del RBAC
Al M8 vas construir exigirRol('organitzador') i una política pura a src/autoritzacio/politica.js, que resolia preguntes com ara «pot aquest usuari crear esdeveniments?». Aquí la pregunta és una altra: «pot aquest usuari moure aquesta tasca d'aquest espai?». El rol global no conté la resposta perquè depèn d'una relació que viu a la base de dades.
La solució no és ficar consultes dins de la política —deixaria de ser pura i provable sense base de dades—, sinó carregar el context abans i decidir després.
// src/autoritzacio/politica-espais.js — continua sent PURA: rep el que necessita
const VEURE = ['espai:veure', 'tasca:veure', 'comentari:veure'];
const EDITAR = [...VEURE, 'tasca:crear', 'tasca:editar', 'tasca:moure', 'comentari:crear'];
const PERMISOS_PER_PAPER = { observador: VEURE, collaborador: EDITAR, responsable: [...EDITAR,
'tasca:esborrar', 'membre:convidar', 'membre:expulsar', 'espai:arxivar'] };
function pot({ actor, pertinenca, accio, recurs = null }) {
// L'administrador global mante la seva clau mestra (M8), auditada a part.
if (actor.rol === 'administrador') return true;
if (!pertinenca) return false;
if (!(PERMISOS_PER_PAPER[pertinenca.paper] ?? []).includes(accio)) return false;
// Regla extra per recurs: un collaborador nomes esborra els seus comentaris.
if (accio === 'comentari:esborrar' && pertinenca.paper === 'collaborador') {
return recurs?.autorId === actor.id;
}
return true;
}
module.exports = { pot, PERMISOS_PER_PAPER };Un middleware carrega la pertinença una vegada per petició i la deixa a peticio.pertinenca sense tallar res —decideix la política, no el middleware—. Un altre, exigirPermis(accio), crida pot(...) i, si diu que no, tria l'error: ACCES_DENEGAT (403) si l'usuari sí que és membre, i ESPAI_NO_TROBAT (404) si no ho és. Aquest matís importa: un 403 confirma que l'espai existeix, i això ja filtra informació sobre l'organització.
I la part important: la comprovació s'empeny a la consulta. Al M8 vas veure la referència directa insegura: demanar /tasques/tar-8814 i rebre-la perquè ningú no va comprovar que fos teva. Amb espais, la temptació és carregar la tasca, mirar-ne l'espaiId i comparar. Funciona, però és fràgil —cal recordar-se'n a cada controlador— i fa dos viatges. Millor: la pertinença forma part del WHERE.
// src/repositoris/tasques-sql.js
async function obtenirTascaVisible({ tascaId, usuariId }) {
const [tasca] = await sequelize.query(
`SELECT t.* FROM tasques t JOIN membres m ON m.espai_id = t.espai_id
WHERE t.id = :tascaId AND m.usuari_id = :usuariId`,
{ replacements: { tascaId, usuariId }, type: SELECT });
return tasca ? aDomini(tasca) : null; // null = no existeix O no la pot veure
}Un repositori que no té manera de retornar una tasca aliena és molt més segur que un que la retorna i confia que algú ho comprovi després: la seguretat que depèn de recordar alguna cosa, tard o d'hora falla. Els llistats segueixen la mateixa regla, amb WHERE t.espai_id IN (SELECT espai_id FROM membres WHERE usuari_id = :usuariId).
- Ordenació manual sense renumerar la llista
En Marc arrossega «revisar el so de l'Auditorio Ribera» des del final de la columna a la segona posició. Amb posicions enteres contigües (1, 2, 3…), inserir a la 2 obliga a sumar u a totes les següents: 300 files actualitzades per arrossegada i conflictes garantits si dues persones arrosseguen alhora.
Posicions fraccionàries. La posició és un nombre real i la tasca es col·loca al punt mitjà entre les seves veïnes:
// src/domini/ordenacio.js — inserir afecta UNA fila
const PAS_INICIAL = 65536;
function calcularPosicio({ anterior, seguent }) {
if (!anterior && !seguent) return PAS_INICIAL;
if (!anterior) return seguent.posicio / 2; // al principi
if (!seguent) return anterior.posicio + PAS_INICIAL; // al final
return (anterior.posicio + seguent.posicio) / 2; // al mig
}
module.exports = { calcularPosicio, PAS_INICIAL };El problema conegut de les fraccions és la precisió: double té 53 bits de mantissa, així que després d'unes 50 insercions al mateix forat dues posicions esdevenen indistingibles. És rar però real, i es resol detectant-ho i recompactant aquella llista —operació cara i molt poc freqüent—:
async function moureTasca({ tascaId, anteriorId, seguentId, espaiId }) {
const [anterior, seguent] = await repositoriTasques.obtenirVeines(anteriorId, seguentId);
if (anterior && seguent && Math.abs(seguent.posicio - anterior.posicio) < 1e-9) {
// S'ha exhaurit l'espai entre veines: renumerem la columna i reintentem.
await repositoriTasques.recompactarColumna({ espaiId, estat: anterior.estat });
return moureTasca({ tascaId, anteriorId, seguentId, espaiId });
}
return repositoriTasques.actualitzarPosicio({ tascaId,
posicio: calcularPosicio({ anterior, seguent }) });
}L'alternativa: LexoRank. En comptes de números, cadenes ordenables lexicogràficament (0|hzzzzz, 0|i00000…). Entre "a" i "b" sempre hi cap "an", i entre "an" i "b" hi cap "anv": mai no s'exhaureix l'espai, només creix la longitud. És el que fa servir Jira; a canvi, la generació és més elaborada i l'ordre depèn de la col·lació de la base de dades.
| Criteri | Fraccions | LexoRank |
|---|---|---|
| Simplicitat | Quatre línies | Llibreria o algorisme propi |
| Límit | Precisió del double (~50 insercions al mateix punt) |
Cap de pràctic; creix la cadena |
| Índex | NUMERIC/DOUBLE, ordre natural |
TEXT amb col·lació binària obligatòria |
Per a Escena Viva, amb columnes de desenes de tasques, les fraccions són la decisió correcta: més simples, suficients i amb una sortida clara si algun dia deixen de ser-ho. És una decisió petita i molt instructiva perquè mostra el patró complet: tria el que és simple, coneix-ne el límit, tingues preparat el pla B.
- Edició concurrent: blocatge optimista i 412
La Lucía i en Marc obren la mateixa tasca. La Lucía canvia la prioritat a alta; en Marc, dos segons després, canvia el responsable i desa amb les dades que va carregar abans del canvi de la Lucía. Si acceptem el seu PUT complet, la prioritat torna a mitjana i ningú no se n'assabenta: és l'actualització perduda, l'error més silenciós de les aplicacions col·laboratives.
La resposta és el blocatge optimista del M10 —If-Match i 412— aplicat al camp versio:
// src/controladors/tasques.js — blocatge optimista amb If-Match (M10)
async function actualitzarTasca(peticio, resposta, seguent) {
const versioEsperada = peticio.get('If-Match');
// Exigir la precondicio evita l'"oblit" del client, que reintrodueix l'error.
if (!versioEsperada) return seguent(new ErrorDAplicacio('PRECONDICIO_REQUERIDA', 428));
const actualitzada = await repositoriTasques.actualitzarSiVersio({
tascaId: peticio.params.tascaId, actorId: peticio.usuari.id,
versio: Number(versioEsperada.replaceAll('"', '')),
canvis: peticio.dadesValidades }); // zod del M6
if (!actualitzada) {
// Retornem l'estat actual: el client pot mostrar la diferencia sense
// una segona peticio i sense perdre el que l'usuari tenia escrit.
const actual = await repositoriTasques.obtenirTascaVisible(
{ tascaId: peticio.params.tascaId, usuariId: peticio.usuari.id });
return resposta.status(412).json({ error: { codi: 'CONFLICTE_DE_VERSIO', estat: 412,
missatge: "Una altra persona ha modificat la tasca mentre l'editaves",
detalls: { versioActual: actual.versio, tascaActual: actual } } });
}
resposta.set('ETag', `"${actualitzada.versio}"`);
return resposta.json(actualitzada);
}Al repositori, la condició viatja dins de l'UPDATE: UPDATE tasques SET … versio = versio + 1 WHERE id = :tascaId AND versio = :versio RETURNING *. Si una altra persona s'ha avançat, el filtre no encaixa, no s'actualitza cap fila i RETURNING retorna el conjunt buit que traduïm a null. És atòmic i no necessita transacció explícita: la mateixa idea del findOneAndUpdate condicional del 12-01, ara en SQL.
Què ha de fer el client davant d'un 412. No recarregar en silenci (perdria el que ha escrit) ni forçar la desada (tornaríem al principi). El correcte: mostrar què ha canviat l'altra persona, conservar el que l'usuari tenia escrit i oferir «conservar el meu» o «quedar-me amb el seu». I una millora barata: si els camps tocats no se solapen (la Lucía va tocar prioritat, en Marc responsable), es pot fusionar automàticament i avisar. Enviar PATCH amb només els camps modificats, en comptes de PUT amb la tasca sencera, redueix moltíssim la freqüència de conflictes reals.
- El repte tècnic: activitat i notificacions
L'activitat és la font de veritat de «què ha passat». No és un registre decoratiu: és una taula de només inserció on cada canvi del domini deixa un fet amb actor, moment i dades:
// src/serveis/activitat.js
const TIPUS = { TASCA_CREADA: 'tasca.creada', TASCA_ASSIGNADA: 'tasca.assignada',
TASCA_ESTAT_CANVIAT: 'tasca.estat_canviat', TASCA_MOGUDA: 'tasca.moguda',
COMENTARI_CREAT: 'comentari.creat', MEMBRE_CONVIDAT: 'membre.convidat' };
// S'escriu DINS de la mateixa transaccio que el canvi: si el canvi es
// desfa, l'activitat tambe. Mai no hi ha un fet sense el seu efecte. A `dades`
// hi va el detall, p. ex. { de: 'pendent', a: 'en_curs' }.
const registrarActivitat = ({ transaccio, espaiId, tascaId, actorId, tipus, dades }) =>
repositoriActivitat.inserir({ espaiId, tascaId, actorId, tipus, dades,
ocorregutEl: new Date().toISOString() }, transaccio);Aquesta taula s'assembla molt a l'abastament d'esdeveniments (event sourcing), on l'estat es reconstrueix reproduint els fets. Aquí no arribem tan lluny: mantenim l'estat actual a tasques i l'activitat és un registre paral·lel. És un compromís deliberat —les consultes continuen sent trivials— i convé saber que existeix la versió pura per si algun dia el requisit és «reconstrueix el tauler tal com era el dimarts». Regla de la taula: mai no s'actualitza ni s'esborra; si alguna cosa es va registrar malament, se'n registra una correcció. Un historial editable no és un historial.
Notificacions: el problema dels 40 correus. En Marc reorganitza el tauler del Festival: mou 12 tasques, en reassigna 5 i comenta en 3. Si cada fet dispara un correu, la Lucía rep 20 correus en quatre minuts i crea un filtre per no tornar-ne a veure cap: la notificació deixa de funcionar. La solució és agrupar i diferir amb BullMQ (M10) fent servir una finestra de silenci —el primer fet encua un treball amb retard i els següents s'acumulen sense encuar res de nou—.
// src/serveis/notificacions.js
const FINESTRA_AGRUPACIO_MS = 5 * 60 * 1000;
async function encuarNotificacio({ destinatariId, activitat, redis, cuaNotificacions }) {
const preferencies = await repositoriPreferencies.obtenir(destinatariId);
if (!preferencies.rep(activitat.tipus)) return; // l'usuari ha manat silenci
if (activitat.actorId === destinatariId) return; // ningu no es notifica a si mateix
const clau = `pendents:${destinatariId}`;
await redis.rpush(clau, JSON.stringify(activitat)); // cada fet s'acumula
await redis.expire(clau, 3600); // xarxa de seguretat si el consumidor cau
// jobId determinista + delay: si el treball ja existeix, no es duplica. El primer
// fet obre la finestra; els altres se sumen al mateix enviament.
await cuaNotificacions.add('resum-activitat', { destinatariId },
{ delay: FINESTRA_AGRUPACIO_MS, jobId: `resum-${destinatariId}` });
}
// src/cues/consumidor-notificacions.js — proces a part (M10/M11)
new Worker('notificacions', async ({ data }) => {
const clau = `pendents:${data.destinatariId}`;
// Llegir i buidar en una sola operacio atomica: si arriben fets nous
// mentre enviem, obren una finestra nova en comptes de perdre's.
const crus = await redis.multi().lrange(clau, 0, -1).del(clau).exec();
const fets = (crus[0][1] ?? []).map((h) => JSON.parse(h));
if (fets.length === 0) return { enviats: 0 };
const destinatari = await repositoriUsuaris.obtenir(data.destinatariId);
await serveiCorreu.enviar({
a: destinatari.correu, // [email protected]
assumpte: fets.length === 1 ? resumirFet(fets[0])
: `${fets.length} novetats als teus espais d'Escena Viva`,
cos: plantillaResum({ fets: agruparPerEspai(fets) }) });
return { enviats: 1, fets: fets.length };
}, { connection, concurrency: 5 });Les preferències per usuari són part del disseny, no un extra: canal (correu, dins l'aplicació, tots dos), tipus de fet que interessen i finestra d'agrupació (immediata, 5 minuts, resum diari). Un sistema de notificacions sense preferències acaba silenciat del tot.
- Tauler en viu amb Socket.IO
Aquí els projectes es connecten: la infraestructura del 12-01 —Socket.IO sobre l'http.Server, autenticació al handshake, adaptador de Redis per a diversos processos— es reutilitza tal qual, canviant només l'espai de noms (/tauler/v1) i les sales de conversa (espai:<id>). El gestor d'espai:subscriure aplica la mateixa regla del punt 3: consulta cercarPertinenca, retorna ESPAI_NO_TROBAT si no n'hi ha i només llavors fa socket.join. La pertinença decideix també aquí, i es consulta: mai no es confia en l'espaiId que envia el client.
I l'emissió no es dispara des del controlador a mà, sinó des del mateix lloc que ja registra l'activitat, un únic punt de veritat: el servei publicarActivitat emet activitat:nova a la sala espai:<id> de /tauler/v1 —per a qui està mirant el tauler ara mateix— i recorre repositoriEspais.membresInteressats(activitat) cridant encuarNotificacio per a qui no hi és mirant.
Compte amb un detall del M10: l'io viu al procés web i les notificacions es processen al consumidor, així que l'adaptador de Redis del 12-01 és el que permet que una emissió feta des de qualsevol procés arribi a tots els sockets.
- Venciments, filtres i informes
Dos treballs programats de BullMQ resolen els venciments: un de repetible diari (repeat: { pattern: '0 8 * * *', tz: 'Europe/Madrid' }) que avisa del que venç demà, i un de diferit per tasca que recorda 24 hores abans de la seva data, amb jobId: recordatori-<tascaId> perquè reprogramar substitueixi en comptes d'acumular recordatoris zombis. Com al 12-03, el consumidor revalida abans d'actuar: si la tasca ja està feta o la data ha canviat, el treball es descarta; un treball diferit sempre pot executar-se en un món diferent del que el va crear. El filtre típic de l'equip, a més, és combinat —«tasques de l'Auditorio Ribera, en curs o bloquejades, assignades a en Marc, que vencen aquesta setmana»— i sense l'índex adequat això és un recorregut seqüencial de la taula.
-- L'ordre segueix l'us: espai_id primer perque SEMPRE hi es (punt 3).
CREATE INDEX idx_tasques_espai_estat_venciment ON tasques (espai_id, estat, venciment);
-- Index PARCIAL: nomes el que esta obert, que es el que es consulta cada dia.
CREATE INDEX idx_tasques_assignat_obertes ON tasques (assignat_a, venciment)
WHERE estat <> 'feta';
CREATE INDEX idx_tasques_etiquetes ON tasques USING GIN (etiquetes);L'índex parcial és una d'aquelles eines que passen desapercebudes: la majoria de tasques d'un espai veterà estan feta, així que un índex que les exclou és molt més petit i més ràpid, i serveix justament la consulta que es fa cent vegades al dia. Comprova-ho sempre amb EXPLAIN ANALYZE (M7): un índex que el planificador no fa servir és només cost d'escriptura. Els informes de l'espai són agregació pura:
SELECT estat, COUNT(*) AS total, COUNT(*) FILTER (WHERE venciment < NOW()) AS vencudes,
AVG(EXTRACT(EPOCH FROM (actualitzat_el - creat_el)) / 3600)
FILTER (WHERE estat = 'feta') AS hores_mitjanes
FROM tasques WHERE espai_id = :espaiId GROUP BY estat;
- Exportació a CSV amb streams
«Exporta totes les tasques de l'espai a CSV» sembla trivial fins que l'espai té 200.000 files històriques. Carregar-les en un array i retornar un resposta.send gegant consumeix centenars de megues i bloqueja el procés mentre serialitza. És exactament el problema que va resoldre el M3: fluir, no acumular.
// src/controladors/exportacio.js
const { pipeline } = require('node:stream/promises');
const { Transform } = require('node:stream');
async function exportarTasquesCsv(peticio, resposta) {
const { espaiId } = peticio.params;
resposta.setHeader('Content-Type', 'text/csv; charset=utf-8');
resposta.setHeader('Content-Disposition', `attachment; filename="tasques-${espaiId}.csv"`);
// Stream del cursor de la base de dades: les files arriben d'una en una.
const streamDeFiles = repositoriTasques.streamDeTasques({ espaiId });
const aCsv = new Transform({
objectMode: true,
construct(fi) { this.push('id,titol,estat,prioritat,venciment,assignat_a\n'); fi(); },
transform(tasca, codificacio, fi) {
this.push([tasca.id, escaparCampCsv(tasca.titol), tasca.estat, tasca.prioritat,
tasca.venciment ? new Date(tasca.venciment).toISOString() : '',
tasca.assignatA ?? ''].join(',') + '\n');
fi();
} });
try {
// pipeline (M3) propaga errors i destrueix els streams si el client avorta,
// de manera que el cursor no queda obert consumint una connexio del pool.
await pipeline(streamDeFiles, aCsv, resposta);
} catch (error) {
// La resposta ja ha comencat: no es pot enviar JSON d'error, nomes tallar.
peticio.registrador.error({ error, espaiId }, 'fallada exportant CSV');
resposta.destroy();
}
}
// Un titol amb coma, cometa o salt de linia trenca el CSV si no s'escapa.
const escaparCampCsv = (valor) => /[",\n\r]/.test(String(valor ?? ''))
? `"${String(valor).replaceAll('"', '""')}"` : String(valor ?? '');L'ús de memòria és constant —uns quants megues— sigui quin sigui el volum.
- Proves de la política de permisos i què queda fora
Amb permisos que depenen d'una relació, les proves d'autorització deixen de ser opcionals. La política és pura: es prova en mil·lisegons i sense base de dades.
describe("politica d'espais", () => {
const marc = { id: 'usr-marc', rol: 'organitzador' };
it('un observador no pot crear tasques', () => {
expect(pot({ actor: marc, pertinenca: { paper: 'observador' }, accio: 'tasca:crear' }))
.to.equal(false);
});
it('sense pertinenca no pot res, encara que el seu rol global sigui organitzador', () => {
expect(pot({ actor: marc, pertinenca: null, accio: 'tasca:veure' })).to.equal(false);
});
it("l'administrador global si que pot", () => {
expect(pot({ actor: { id: 'usr-admin', rol: 'administrador' },
pertinenca: null, accio: 'espai:arxivar' })).to.equal(true);
});
});
describe('acces per pertinenca', () => { // integracio amb supertest (M9)
it("retorna 404 en demanar una tasca d'un espai alie", async () => {
const tasca = await crearTasca({ espaiId: 'esp-ribera', titol: 'Revisar el so' });
await peticio(aplicacio).get(`/api/v1/tasques/${tasca.id}`)
.set('Authorization', `Bearer ${tokenDe('usr-lucia')}`).expect(404);
});
it("no llista tasques d'espais als quals no pertany", async () => {
const { body } = await peticio(aplicacio).get('/api/v1/tasques?venciment=setmana')
.set('Authorization', `Bearer ${tokenDe('usr-lucia')}`).expect(200);
expect(body.tasques.every((t) => t.espaiId === 'esp-almendra')).to.equal(true);
});
});Falta la del punt 5: un PATCH amb If-Match: "2" sobre una tasca en versió 3 ha de retornar 412. I l'última de les escrites és la que més vegades salva d'una filtració real: no comprova un permís concret, comprova que el llistat no s'escola. Afegeix-la per a cada punt d'entrada de llistat que existeixi.
| Fora | Per què | Ampliació |
|---|---|---|
| Plantilles d'espai («muntatge estàndard de sala») | És una capa sobre el que ja s'ha construït | Clonar un espai amb les seves tasques i dependències relatives |
| Diagrama de Gantt i camí crític | Càlcul i front-end considerables | Graf de dependències + ordre topològic al servidor |
| Camps personalitzats per espai | Model i validació dinàmics | Columna JSONB + esquema zod generat per espai |
| Adjunts i integracions externes | Ja resolt al 12-03; cada integració és un projecte | Reutilitzar la canonada de pujada; webhooks sortints signats |
Errors Comuns i Consells
- Comprovar la pertinença al controlador després de carregar el recurs. Funciona fins que algú afegeix un punt d'entrada i se n'oblida. Empeny el filtre a la consulta del repositori.
- Retornar 403 quan l'usuari no és membre. Un 403 confirma que l'espai existeix; per a recursos privats, 404. I no acceptis
PUTsenseIf-Match: és obrir la porta a l'actualització perduda, així que retorna 428 si falta la precondició. - Renumerar la llista sencera en arrossegar. Posicions fraccionàries, amb recompactació com a excepció. I no actualitzis ni esborris files d'
activitat: deixa de ser font de veritat en el moment que és editable. - Notificar cada fet per separat. L'usuari silencia el canal i perds l'únic mitjà per avisar-lo.
- Consell: inclou l'
idPeticio(M11) a cada fila d'activitat. Quan algú pregunti «per què aquesta tasca va canviar de responsable?», podràs creuar el fet amb els registres de pino d'aquella petició exacta.
Exercicis
-
Dependències sense cicles. Implementa
crearDependencia(tascaId, depenDeId)que rebutgi amb 409 qualsevol dependència que creï un cicle (A → B → C → A). Escriu proves per al cicle directe i per a l'indirecte. -
Reassignació massiva amb activitat. Implementa
POST /espais/:id/tasques/reassignarque canviï el responsable de N tasques en una transacció, registri un fet per tasca i produeixi un sol correu agrupat. Prova que el destinatari rep un enviament i no N. -
Vista «el meu dia». Retorna les tasques de l'usuari que vencen avui o ja estan vençudes, de tots els seus espais, ordenades per prioritat i venciment, en una sola consulta que faci servir l'índex parcial.
Solucions
1. Dependències sense cicles
// src/serveis/dependencies.js
async function crearDependencia({ tascaId, depenDeId, actorId }) {
if (tascaId === depenDeId) throw new ConflicteDEstat('DEPENDENCIA_CIRCULAR', { tascaId });
// Recorregut en amplada: si des de depenDeId es pot arribar a tascaId,
// afegir aquesta aresta tancaria el cicle.
const visitats = new Set(); const cua = [depenDeId];
while (cua.length > 0) {
const actual = cua.shift();
if (actual === tascaId) throw new ConflicteDEstat('DEPENDENCIA_CIRCULAR',
{ tascaId, depenDeId });
if (visitats.has(actual)) continue;
visitats.add(actual);
cua.push(...(await repositoriTasques.dependenciesDe(actual)).map((p) => p.depenDeId));
}
return repositoriTasques.inserirDependencia({ tascaId, depenDeId, actorId });
}La prova encadena B→A i C→B i comprova que A→C es rebutja amb DEPENDENCIA_CIRCULAR. Amb grafs grans el recorregut per consultes encadenades es paga car: l'alternativa és un WITH RECURSIVE que resol l'abast en un sol viatge.
2. Reassignació massiva agrupada
async function reassignarTasques({ espaiId, tascaIds, nouResponsableId, actor }) {
const fets = await sequelize.transaction(async (t) => {
const acumulats = [];
for (const tascaId of tascaIds) {
const tasca = await repositoriTasques.obtenirPerActualitzar(tascaId, t);
if (tasca?.espaiId !== espaiId) throw new RecursNoTrobat(
'TASCA_NO_TROBADA', { tascaId });
await repositoriTasques.actualitzarEnTransaccio(
{ tascaId, canvis: { assignatA: nouResponsableId } }, t);
acumulats.push(await registrarActivitat({ transaccio: t, espaiId, tascaId,
actorId: actor.id, tipus: TIPUS.TASCA_ASSIGNADA,
dades: { de: tasca.assignatA, a: nouResponsableId } }));
}
return acumulats;
});
// Fora de la transaccio: efectes externs nomes despres de confirmar el commit.
for (const h of fets) await publicarActivitat({ activitat: h, io, cuaNotificacions, redis });
return fets.length;
}L'agrupament surt de franc: els N fets s'acumulen a la llista de Redis i comparteixen el mateix jobId resum-<destinatariId>, així que BullMQ manté un treball diferit. La prova espia serveiCorreu.enviar, avança el rellotge FINESTRA_AGRUPACIO_MS i verifica callCount === 1 amb un assumpte que inclou «12 novetats».
3. Vista «el meu dia»
async function tasquesDelMeuDia({ usuariId }) {
const [files] = await sequelize.query(
`SELECT t.*, e.nom AS espai_nom
FROM tasques t JOIN espais e ON e.id = t.espai_id
WHERE t.assignat_a = :usuariId AND t.estat <> 'feta'
AND t.venciment < (CURRENT_DATE + INTERVAL '1 day')
AND EXISTS (SELECT 1 FROM membres m
WHERE m.espai_id = t.espai_id AND m.usuari_id = :usuariId)
ORDER BY CASE t.prioritat WHEN 'alta' THEN 0 WHEN 'mitjana' THEN 1 ELSE 2 END, t.venciment
LIMIT 100`, { replacements: { usuariId } });
return files.map(aDomini);
}L'EXISTS sobre membres sembla redundant —la tasca ja està assignada a l'usuari— però cobreix el cas d'algú expulsat de l'espai que conserva tasques assignades: sense ell continuaria veient feina d'un espai al qual ja no pertany. El WHERE estat <> 'feta' encaixa amb l'índex parcial del punt 8; confirma-ho amb EXPLAIN ANALYZE.
Conclusió
L'eina interna tanca els quatre projectes amb el repte que només apareix quan diverses persones comparteixen els mateixos objectes. Has vist que el RBAC del Mòdul 8 no mor, s'estén: la política continua sent una funció pura, però rep la pertinença a més del rol, i —la part que de debò protegeix— la comprovació s'empeny al WHERE del repositori, perquè retornar un recurs aliè sigui impossible en lloc d'improbable.
Has resolt l'actualització perduda amb blocatge optimista i una resposta 412 que dóna al client tot el que li cal per reconciliar; has ordenat llistes sense reescriure-les amb posicions fraccionàries, coneixent-ne el límit i el pla B; i has construït un registre d'activitat immutable que serveix alhora d'historial, de disparador del temps real del 12-01 i d'origen d'unes notificacions agrupades. Amb això queden construïts els quatre productes satèl·lit d'Escena Viva. L'última lliçó del curs no hi afegeix cap projecte més: recull el viatge sencer, converteix el que has après en una llista de comprovació de posada en producció i en un marc per prendre decisions tècniques, i traça amb honestedat cap on continuar quan el curs s'acabi.
Curs de Node.js: De Principiant a Avançat
Mòdul 1: Introducció a Node.js
- Què és Node.js?
- Instal·lació i Configuració de l'Entorn
- El Teu Primer Programa en Node.js
- El REPL de Node.js
- JavaScript Modern per a Node.js
- El Projecte del Curs: la Plataforma Escena Viva
Mòdul 2: Conceptes Bàsics
- Arquitectura de Node.js
- El Bucle d'Esdeveniments (Event Loop)
- Callbacks i Programació Asíncrona
- Promeses i async/await
- Esdeveniments i EventEmitter
- Mòduls CommonJS i require()
- Mòduls ES i Interoperabilitat
Mòdul 3: Sistema de Fitxers i E/S
- Lectura i Escriptura de Fitxers
- El Mòdul fs a Fons
- Rutes Multiplataforma amb el Mòdul path
- Treballant amb Streams
- Streams de Transformació i pipeline
- Buffers i Dades Binàries
Mòdul 4: HTTP i Servidors Web
- Creant un Servidor HTTP Simple
- Gestió de Sol·licituds i Respostes
- Enrutament Manual
- Servint Fitxers Estàtics
- Rebent Dades: Cossos de Petició i JSON
- Consumint APIs Externes des de Node.js
Mòdul 5: NPM i Gestió de Paquets
- Introducció a NPM i package.json
- Instal·lació i Ús de Paquets
- Versionat Semàntic i package-lock
- Scripts d'npm i Automatització del Projecte
- Creació i Publicació de Paquets
- Seguretat i Manteniment de Dependències
Mòdul 6: Framework Express.js
- Introducció a Express.js
- Configuració d'una Aplicació Express
- Enrutament a Express
- Middleware
- Middleware de Tercers Essencials
- Validació de Dades d'Entrada
- Gestió d'Errors
Mòdul 7: Bases de Dades i ORMs
- Introducció a les Bases de Dades
- Usant MongoDB amb Mongoose
- Operacions CRUD
- Relacions, Poblat i Consultes Avançades
- Usant Bases de Dades SQL amb Sequelize
- Migracions, Transaccions i Dades de Prova
Mòdul 8: Autenticació i Autorització
- Introducció a l'Autenticació
- Registre d'Usuaris i Hash de Contrasenyes
- Sessions i Galetes amb Passport.js
- Autenticació amb JWT
- Control d'Accés Basat en Rols
- Bones Pràctiques de Seguretat en APIs
Mòdul 9: Proves i Depuració
- Introducció a les Proves
- Proves Unitàries amb Mocha i Chai
- Dobles de Prova amb Sinon
- Proves d'Integració
- Cobertura i Automatització de les Proves
- Depuració d'Aplicacions Node.js
Mòdul 10: Temes Avançats
- El Mòdul Cluster
- Fils de Treball (Worker Threads)
- Memòria Cau i Cues de Treball amb Redis
- Optimització del Rendiment
- Construcció d'APIs RESTful
- GraphQL amb Node.js
Mòdul 11: Desplegament i DevOps
- Configuració i Variables d'Entorn
- Registre i Monitoratge en Producció
- Usant PM2 per a la Gestió de Processos
- Empaquetatge amb Docker
- Desplegant a Heroku i Altres PaaS
- Integració i Desplegament Continus
