diff --git a/docs/01-vision-general.md b/docs/01-vision-general.md index eae36fc..1b840b8 100644 --- a/docs/01-vision-general.md +++ b/docs/01-vision-general.md @@ -27,7 +27,7 @@ | 🗺️ Hoja de ruta | Las épicas en el tiempo (fecha de inicio y límite), con los sprints y las releases como referencias | | 🌐 Web del colectivo | La página de cada proyecto en pireon.dev enseña su hoja de ruta, versiones, sprint y tickets para recién llegados (`good-first-issue`) | | 🧩 Desglose | Épicas → tickets → subtareas (tres niveles), con su progreso; y una checklist dentro de cada ticket para los pasos pequeños | -| 👀 Seguir | Cualquier miembro sigue los tickets que le interesan y los ve juntos en «Que sigo»; quien comenta, los sigue solo | +| 👀 Seguir | Cualquier miembro sigue los tickets que le interesan y los ve juntos en «Que sigo»; quien lo abre, lo tiene asignado o comenta, lo sigue solo | | 🔔 Avisos | Campana con lo que te concierne (asignaciones, cambios de estado, QA fallido, comentarios, respuestas), en vivo y opcionalmente por correo | | ⏰ Recordatorios | Aviso (y correo, si se quiere) el día antes, el mismo día y al vencer la fecha límite, a quien lo tiene y a quien lo sigue; las tarjetas marcan lo que vence | | 📎 Adjuntos | Capturas, logs y vídeos en el ticket o bajo un comentario; arrastrar, elegir o pegar con Ctrl+V; miniaturas de las imágenes | diff --git a/docs/02-arquitectura.md b/docs/02-arquitectura.md index b26a766..c8900e2 100644 --- a/docs/02-arquitectura.md +++ b/docs/02-arquitectura.md @@ -144,6 +144,7 @@ uno y esperando. Así heredan las garantías del bridge: orden, reintento y nada | `ReleaseArchivedSaga` | `ReleaseArchivedEvent` | `DetachIssueFromReleaseCommand` para sus tickets sin terminar (los terminados se quedan) | | `SprintCarryOverSaga` | `SprintClosedEvent` / `SprintDeletedEvent` | `CarryOverIssueCommand`: lo pendiente pasa al sprint elegido o al backlog; al borrar, todos vuelven al backlog | | `CommentersFollowSaga` | `CommentAddedEvent` | `WatchIssueCommand`: quien comenta pasa a seguir el ticket | +| `PeopleFollowSaga` | `IssueCreatedEvent`, `IssueAssignedEvent` | `WatchIssueCommand`: quien abre el ticket y quien lo tiene asignado pasan a seguirlo | | `UserDeactivatedSaga` | `UserDeactivatedEvent` (contexto identity) | `UnassignIssueCommand` para sus tickets abiertos en todos los proyectos, como actor `system` («TaskStream») | Reglas que cumplen: diff --git a/docs/03-api.md b/docs/03-api.md index 9512895..7068532 100644 --- a/docs/03-api.md +++ b/docs/03-api.md @@ -251,7 +251,8 @@ Nadie recibe aviso de lo que hizo él mismo, y cada persona recibe uno por event **Menciones**: se escriben `@[Nombre](id-de-usuario)` (la web las inserta al escribir `@`); el id dice a quién avisar y el nombre es lo que se ve, así que renombrar a alguien no rompe comentarios antiguos. Solo avisan a miembros del proyecto. **Seguir**: cualquier miembro, también los lectores, puede seguir un ticket (`watchIssue`/`unwatchIssue`, -eventos `IssueWatchedEvent`/`IssueUnwatchedEvent`), y quien comenta pasa a seguirlo (`CommentersFollowSaga`). Quien +eventos `IssueWatchedEvent`/`IssueUnwatchedEvent`), y quien lo abre, quien lo tiene asignado y quien comenta pasan a seguirlo (`PeopleFollowSaga`, +`CommentersFollowSaga`). Quien sigue un ticket (`WATCHED`) sabe cuándo cambia de estado, se asigna, se comenta, cambia su prioridad o su fecha límite, o entra o sale de un sprint; las ediciones menores (título, etiquetas…) no avisan. «Los que sigo»: `watching` en `myIssues` / `GET /api/me/issues` y en la búsqueda de un proyecto (`issues(filter: { watching: true })`, diff --git a/docs/TaskStream-Documentacion.docx b/docs/TaskStream-Documentacion.docx index 4837c3b..e490e5b 100644 Binary files a/docs/TaskStream-Documentacion.docx and b/docs/TaskStream-Documentacion.docx differ diff --git a/docs/TaskStream-Documentacion.pdf b/docs/TaskStream-Documentacion.pdf index da9851e..fa83a72 100644 Binary files a/docs/TaskStream-Documentacion.pdf and b/docs/TaskStream-Documentacion.pdf differ diff --git a/libs/api/tracker/application/src/lib/sagas/people-follow.saga.spec.ts b/libs/api/tracker/application/src/lib/sagas/people-follow.saga.spec.ts new file mode 100644 index 0000000..099298a --- /dev/null +++ b/libs/api/tracker/application/src/lib/sagas/people-follow.saga.spec.ts @@ -0,0 +1,47 @@ +import { + IssueAssignedEvent, + IssueCreatedEvent, + IssuePriority, +} from '@task-stream/api/tracker/domain'; +import { lastValueFrom, of, toArray } from 'rxjs'; +import { WatchIssueCommand } from '../commands/watch-issue.command'; +import { PeopleFollowSaga } from './people-follow.saga'; + +describe('PeopleFollowSaga', () => { + const saga = new PeopleFollowSaga(); + const created = (assigneeId: string | null) => + new IssueCreatedEvent('ana', { + id: 'i-1', + projectId: 'p-1', + reporterId: 'ana', + assigneeId, + priority: IssuePriority.MEDIUM, + } as never); + + it('makes whoever opens a ticket, and whoever gets it, follow it', async () => { + expect( + await lastValueFrom(saga.created(of(created('bob'))).pipe(toArray())), + ).toEqual([ + new WatchIssueCommand('ana', 'i-1', 'ana'), + new WatchIssueCommand('bob', 'i-1', 'bob'), + ]); + // Opening a ticket for oneself: once. + expect( + await lastValueFrom(saga.created(of(created('ana'))).pipe(toArray())), + ).toEqual([new WatchIssueCommand('ana', 'i-1', 'ana')]); + }); + + it('makes whoever a ticket is assigned to follow it, not when unassigned', async () => { + const commands = await lastValueFrom( + saga + .assigned( + of( + new IssueAssignedEvent('ana', 'i-1', 'p-1', 'bob', null), + new IssueAssignedEvent('ana', 'i-1', 'p-1', null, 'bob'), + ), + ) + .pipe(toArray()), + ); + expect(commands).toEqual([new WatchIssueCommand('bob', 'i-1', 'bob')]); + }); +}); diff --git a/libs/api/tracker/application/src/lib/sagas/people-follow.saga.ts b/libs/api/tracker/application/src/lib/sagas/people-follow.saga.ts new file mode 100644 index 0000000..b15a6ed --- /dev/null +++ b/libs/api/tracker/application/src/lib/sagas/people-follow.saga.ts @@ -0,0 +1,42 @@ +import { Injectable } from '@nestjs/common'; +import { ICommand, IEvent, ofType, Saga } from '@nestjs/cqrs'; +import { + IssueAssignedEvent, + IssueCreatedEvent, +} from '@task-stream/api/tracker/domain'; +import { filter, mergeMap, Observable } from 'rxjs'; +import { WatchIssueCommand } from '../commands/watch-issue.command'; + +/** Follows a ticket as `userId` would by pressing "Seguir". */ +const follow = (userId: string, issueId: string) => + new WatchIssueCommand(userId, issueId, userId); + +/** + * Whoever opens a ticket and whoever it is assigned to follow it from then + * on, as a commenter does: its changes concern them. Unfollowing stays + * theirs to do. (Idempotent: following twice changes nothing; someone who + * is not a member, like the system, is refused and skipped.) + */ +@Injectable() +export class PeopleFollowSaga { + @Saga() + created = (events$: Observable): Observable => + events$.pipe( + ofType(IssueCreatedEvent), + mergeMap(({ issue }) => + [...new Set([issue.reporterId, issue.assigneeId])] + .filter((userId): userId is string => !!userId) + .map((userId) => follow(userId, issue.id)), + ), + ); + + @Saga() + assigned = (events$: Observable): Observable => + events$.pipe( + ofType(IssueAssignedEvent), + filter((event) => !!event.assigneeId), + mergeMap((event) => [ + follow(event.assigneeId as string, event.aggregateId), + ]), + ); +} diff --git a/libs/api/tracker/application/src/lib/sagas/sagas.ts b/libs/api/tracker/application/src/lib/sagas/sagas.ts index f213700..da387d5 100644 --- a/libs/api/tracker/application/src/lib/sagas/sagas.ts +++ b/libs/api/tracker/application/src/lib/sagas/sagas.ts @@ -2,6 +2,7 @@ import { ProjectDeletionSaga } from './project-deletion.saga'; import { ReleaseArchivedSaga } from './release-archived.saga'; import { UserDeactivatedSaga } from './user-deactivated.saga'; import { CommentersFollowSaga } from './commenters-follow.saga'; +import { PeopleFollowSaga } from './people-follow.saga'; import { SprintCarryOverSaga } from './sprint-carry-over.saga'; /** Reactions to events that change other aggregates (run by the events bridge). */ @@ -10,5 +11,6 @@ export const SAGAS = [ ReleaseArchivedSaga, UserDeactivatedSaga, CommentersFollowSaga, + PeopleFollowSaga, SprintCarryOverSaga, ];