feat: whoever opens or gets a ticket follows it
Like a commenter, whoever opens a ticket and whoever it is assigned to (also on reassigning) follow it from then on, so they hear of its priority, due date and sprint changes and find it under "Que sigo". Existing tickets are left as they are. Docs, Word and PDF updated. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -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 |
|
| 🗺️ 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`) |
|
| 🌐 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 |
|
| 🧩 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 |
|
| 🔔 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 |
|
| ⏰ 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 |
|
| 📎 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 |
|
||||||
|
|||||||
@@ -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) |
|
| `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 |
|
| `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 |
|
| `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») |
|
| `UserDeactivatedSaga` | `UserDeactivatedEvent` (contexto identity) | `UnassignIssueCommand` para sus tickets abiertos en todos los proyectos, como actor `system` («TaskStream») |
|
||||||
|
|
||||||
Reglas que cumplen:
|
Reglas que cumplen:
|
||||||
|
|||||||
@@ -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
|
**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
|
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`,
|
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,
|
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
|
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 })`,
|
`myIssues` / `GET /api/me/issues` y en la búsqueda de un proyecto (`issues(filter: { watching: true })`,
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
@@ -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')]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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<IEvent>): Observable<ICommand> =>
|
||||||
|
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<IEvent>): Observable<ICommand> =>
|
||||||
|
events$.pipe(
|
||||||
|
ofType(IssueAssignedEvent),
|
||||||
|
filter((event) => !!event.assigneeId),
|
||||||
|
mergeMap((event) => [
|
||||||
|
follow(event.assigneeId as string, event.aggregateId),
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ import { ProjectDeletionSaga } from './project-deletion.saga';
|
|||||||
import { ReleaseArchivedSaga } from './release-archived.saga';
|
import { ReleaseArchivedSaga } from './release-archived.saga';
|
||||||
import { UserDeactivatedSaga } from './user-deactivated.saga';
|
import { UserDeactivatedSaga } from './user-deactivated.saga';
|
||||||
import { CommentersFollowSaga } from './commenters-follow.saga';
|
import { CommentersFollowSaga } from './commenters-follow.saga';
|
||||||
|
import { PeopleFollowSaga } from './people-follow.saga';
|
||||||
import { SprintCarryOverSaga } from './sprint-carry-over.saga';
|
import { SprintCarryOverSaga } from './sprint-carry-over.saga';
|
||||||
|
|
||||||
/** Reactions to events that change other aggregates (run by the events bridge). */
|
/** Reactions to events that change other aggregates (run by the events bridge). */
|
||||||
@@ -10,5 +11,6 @@ export const SAGAS = [
|
|||||||
ReleaseArchivedSaga,
|
ReleaseArchivedSaga,
|
||||||
UserDeactivatedSaga,
|
UserDeactivatedSaga,
|
||||||
CommentersFollowSaga,
|
CommentersFollowSaga,
|
||||||
|
PeopleFollowSaga,
|
||||||
SprintCarryOverSaga,
|
SprintCarryOverSaga,
|
||||||
];
|
];
|
||||||
|
|||||||
Reference in New Issue
Block a user