From 88928b402fc4a067426b64aa181b6250f44e54b1 Mon Sep 17 00:00:00 2001 From: Josep Piguillem Date: Thu, 8 Oct 2026 09:41:32 +0200 Subject: [PATCH] feat: mentions and following tickets Mention project members in comments with @, follow and unfollow tickets, and get told when something changes on the tickets you follow. Commenting on a ticket starts following it. Co-Authored-By: Claude Opus 5.5 --- apps/api/openapi.json | 209 ++++++++++++++++++ apps/api/schema.gql | 11 + apps/mcp/src/app/api/taskstream.schema.ts | 175 +++++++++++++++ docs/01-vision-general.md | 3 +- docs/02-arquitectura.md | 13 +- docs/03-api.md | 30 ++- .../src/lib/commands/command-handlers.ts | 2 + .../src/lib/commands/commands.spec.ts | 23 ++ .../application/src/lib/commands/index.ts | 1 + .../commands/watch-issue.command-handler.ts | 33 +++ .../src/lib/commands/watch-issue.command.ts | 14 ++ .../src/lib/event-handlers/event-handlers.ts | 2 + .../issue-watchers.event-handler.ts | 40 ++++ .../application/src/lib/issues.service.ts | 12 + .../notifications.event-handler.ts | 68 ++++-- .../lib/notifications/notifications.spec.ts | 54 ++++- .../upsert-materialized-issue.repository.ts | 11 +- .../lib/sagas/commenters-follow.saga.spec.ts | 25 +++ .../src/lib/sagas/commenters-follow.saga.ts | 27 +++ .../application/src/lib/sagas/sagas.ts | 2 + libs/api/tracker/domain/src/index.ts | 1 + .../tracker/domain/src/lib/events/index.ts | 2 + .../src/lib/events/issue-unwatched.event.ts | 15 ++ .../src/lib/events/issue-watched.event.ts | 15 ++ .../domain/src/lib/issue/issue.spec.ts | 28 +++ .../api/tracker/domain/src/lib/issue/issue.ts | 26 +++ .../tracker/domain/src/lib/issue/mentions.ts | 17 ++ .../src/lib/read-models/issue.read-model.ts | 2 + .../src/lib/mongo/read-model.schemas.ts | 3 + .../mongo/upsert-materialized.repositories.ts | 23 +- .../src/lib/graphql/issue.resolver.ts | 19 ++ .../presenters/src/lib/graphql/types.ts | 2 + .../src/lib/http/dto/responses.dto.ts | 2 + .../src/lib/http/issues.controller.ts | 30 +++ .../src/lib/events/issue.events.ts | 1 + .../src/lib/facades/issue.facade.ts | 9 + .../application/src/lib/stores/issue.store.ts | 5 + libs/web/tracker/domain/src/index.ts | 1 + .../domain/src/lib/models/mentions.spec.ts | 30 +++ .../tracker/domain/src/lib/models/mentions.ts | 52 +++++ .../tracker/domain/src/lib/models/models.ts | 2 + .../tracker/domain/src/lib/ports/gateways.ts | 2 + .../lib/adapters/graphql-issues.gateway.ts | 12 + .../src/lib/graphql/fragments.graphql | 3 + .../src/lib/graphql/generated.ts | 181 +++++++++++++++ .../src/lib/graphql/issues.graphql | 12 + .../presenters/src/lib/pages/issue.page.scss | 36 +++ .../presenters/src/lib/pages/issue.page.ts | 71 +++++- .../src/lib/ui/notifications-bell.ts | 2 +- 49 files changed, 1318 insertions(+), 41 deletions(-) create mode 100644 libs/api/tracker/application/src/lib/commands/watch-issue.command-handler.ts create mode 100644 libs/api/tracker/application/src/lib/commands/watch-issue.command.ts create mode 100644 libs/api/tracker/application/src/lib/event-handlers/issue-watchers.event-handler.ts create mode 100644 libs/api/tracker/application/src/lib/sagas/commenters-follow.saga.spec.ts create mode 100644 libs/api/tracker/application/src/lib/sagas/commenters-follow.saga.ts create mode 100644 libs/api/tracker/domain/src/lib/events/issue-unwatched.event.ts create mode 100644 libs/api/tracker/domain/src/lib/events/issue-watched.event.ts create mode 100644 libs/api/tracker/domain/src/lib/issue/mentions.ts create mode 100644 libs/web/tracker/domain/src/lib/models/mentions.spec.ts create mode 100644 libs/web/tracker/domain/src/lib/models/mentions.ts diff --git a/apps/api/openapi.json b/apps/api/openapi.json index 6273c9b..38c5f69 100644 --- a/apps/api/openapi.json +++ b/apps/api/openapi.json @@ -1719,6 +1719,207 @@ ] } }, + "/api/issues/{issue}/watch": { + "put": { + "description": "Followers are told when it moves, is reassigned or gets a comment. Commenting follows it too.", + "operationId": "IssuesController_watch", + "parameters": [ + { + "name": "issue", + "required": true, + "in": "path", + "description": "Ticket id or key", + "schema": { + "example": "WEB-12", + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CommandResultDto" + } + } + } + }, + "400": { + "description": "Invalid input", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorDto" + } + } + } + }, + "401": { + "description": "Missing or rejected Pireon token", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorDto" + } + } + } + }, + "403": { + "description": "Not a member of the project (or not allowed)", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorDto" + } + } + } + }, + "404": { + "description": "Not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorDto" + } + } + } + }, + "409": { + "description": "Someone changed it at the same time: retry", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorDto" + } + } + } + }, + "422": { + "description": "Business rule (e.g. a transition the workflow forbids)", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorDto" + } + } + } + } + }, + "security": [ + { + "pireon-oauth": [] + }, + { + "pireon": [] + } + ], + "summary": "Follow a ticket (members, viewers included)", + "tags": [ + "Issues" + ] + }, + "delete": { + "operationId": "IssuesController_unwatch", + "parameters": [ + { + "name": "issue", + "required": true, + "in": "path", + "description": "Ticket id or key", + "schema": { + "example": "WEB-12", + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CommandResultDto" + } + } + } + }, + "400": { + "description": "Invalid input", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorDto" + } + } + } + }, + "401": { + "description": "Missing or rejected Pireon token", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorDto" + } + } + } + }, + "403": { + "description": "Not a member of the project (or not allowed)", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorDto" + } + } + } + }, + "404": { + "description": "Not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorDto" + } + } + } + }, + "409": { + "description": "Someone changed it at the same time: retry", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorDto" + } + } + } + }, + "422": { + "description": "Business rule (e.g. a transition the workflow forbids)", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorDto" + } + } + } + } + }, + "security": [ + { + "pireon-oauth": [] + }, + { + "pireon": [] + } + ], + "summary": "Stop following a ticket", + "tags": [ + "Issues" + ] + } + }, "/api/me/issues": { "get": { "description": "Assigned to me by default; with assignedToMe=false, any ticket of my projects (e.g. waiting for QA).", @@ -6132,6 +6333,13 @@ "$ref": "#/components/schemas/AttachmentDto" } }, + "watchers": { + "description": "People following it", + "type": "array", + "items": { + "$ref": "#/components/schemas/UserRefDto" + } + }, "commentCount": { "type": "number" }, @@ -6170,6 +6378,7 @@ "dueDate", "links", "attachments", + "watchers", "commentCount", "createdAt", "updatedAt", diff --git a/apps/api/schema.gql b/apps/api/schema.gql index b6d597e..9bb62b8 100644 --- a/apps/api/schema.gql +++ b/apps/api/schema.gql @@ -220,6 +220,9 @@ type Issue { type: String! updatedAt: String! version: Int! + + """People following the ticket""" + watchers: [UserRef!]! } type IssueChange { @@ -359,6 +362,9 @@ type Mutation { """Put a ticket in a release (null removes it)""" setIssueRelease(issueId: ID!, releaseId: ID): CommandResult! unlinkIssues(input: LinkIssuesInput!): CommandResult! + + """Stop following a ticket""" + unwatchIssue(issueId: ID!): CommandResult! updateIssue(input: UpdateIssueInput!): CommandResult! updateNotificationSettings(email: Boolean!): NotificationSettings! updateProject(input: UpdateProjectInput!): CommandResult! @@ -368,6 +374,11 @@ type Mutation { """ updateProjectSettings(projectId: ID!, settings: ProjectSettingsInput!): CommandResult! updateRelease(input: UpdateReleaseInput!): CommandResult! + + """ + Follow a ticket: be told when it moves, is reassigned or gets a comment + """ + watchIssue(issueId: ID!): CommandResult! } type MyNotifications { diff --git a/apps/mcp/src/app/api/taskstream.schema.ts b/apps/mcp/src/app/api/taskstream.schema.ts index 4e23f5c..2f18415 100644 --- a/apps/mcp/src/app/api/taskstream.schema.ts +++ b/apps/mcp/src/app/api/taskstream.schema.ts @@ -301,6 +301,27 @@ export interface paths { patch: operations["ProjectsController_changeMemberRole"]; trace?: never; }; + "/api/issues/{issue}/watch": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + /** + * Follow a ticket (members, viewers included) + * @description Followers are told when it moves, is reassigned or gets a comment. Commenting follows it too. + */ + put: operations["IssuesController_watch"]; + post?: never; + /** Stop following a ticket */ + delete: operations["IssuesController_unwatch"]; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/me/issues": { parameters: { query?: never; @@ -1093,6 +1114,8 @@ export interface components { dueDate: string | null; links: components["schemas"]["IssueLinkDto"][]; attachments: components["schemas"]["AttachmentDto"][]; + /** @description People following it */ + watchers: components["schemas"]["UserRefDto"][]; commentCount: number; /** Format: date-time */ createdAt: string; @@ -2632,6 +2655,158 @@ export interface operations { }; }; }; + IssuesController_watch: { + parameters: { + query?: never; + header?: never; + path: { + /** @description Ticket id or key */ + issue: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["CommandResultDto"]; + }; + }; + /** @description Invalid input */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiErrorDto"]; + }; + }; + /** @description Missing or rejected Pireon token */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiErrorDto"]; + }; + }; + /** @description Not a member of the project (or not allowed) */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiErrorDto"]; + }; + }; + /** @description Not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiErrorDto"]; + }; + }; + /** @description Someone changed it at the same time: retry */ + 409: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiErrorDto"]; + }; + }; + /** @description Business rule (e.g. a transition the workflow forbids) */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiErrorDto"]; + }; + }; + }; + }; + IssuesController_unwatch: { + parameters: { + query?: never; + header?: never; + path: { + /** @description Ticket id or key */ + issue: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["CommandResultDto"]; + }; + }; + /** @description Invalid input */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiErrorDto"]; + }; + }; + /** @description Missing or rejected Pireon token */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiErrorDto"]; + }; + }; + /** @description Not a member of the project (or not allowed) */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiErrorDto"]; + }; + }; + /** @description Not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiErrorDto"]; + }; + }; + /** @description Someone changed it at the same time: retry */ + 409: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiErrorDto"]; + }; + }; + /** @description Business rule (e.g. a transition the workflow forbids) */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiErrorDto"]; + }; + }; + }; + }; IssuesController_mine: { parameters: { query?: { diff --git a/docs/01-vision-general.md b/docs/01-vision-general.md index 9f693cb..b4936c4 100644 --- a/docs/01-vision-general.md +++ b/docs/01-vision-general.md @@ -13,7 +13,8 @@ | 🧪 QA | Por defecto, `READY_FOR_QA → IN_QA → DONE / QA_FAILED`; fallar QA exige un motivo | | 🚀 Releases | Versiones a las que se asignan tickets; no se publican con tickets sin terminar (salvo forzando) | | 👥 Asignaciones | Solo a miembros del proyecto; panel de carga por persona | -| 💬 Comentarios | Decisiones y explicaciones dentro del ticket; el autor edita, el admin modera | +| 💬 Comentarios | Decisiones y explicaciones dentro del ticket, con `@menciones`; el autor edita, el admin modera | +| 👀 Seguir | Cualquier miembro sigue los tickets que le interesan; quien comenta, los sigue solo | | 🔔 Avisos | Campana con lo que te concierne (asignaciones, cambios de estado, QA fallido, comentarios), en vivo y opcionalmente por correo | | 📎 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 | | 🔗 Relaciones | `BLOCKS`, `DEPENDS_ON`, `TESTS`, `DUPLICATES`, `RELATES_TO`, también entre proyectos (FE ↔ BE ↔ QA) | diff --git a/docs/02-arquitectura.md b/docs/02-arquitectura.md index f24fee3..9ab4d64 100644 --- a/docs/02-arquitectura.md +++ b/docs/02-arquitectura.md @@ -137,12 +137,13 @@ nunca ve los eventos guardados (su publisher es el event store), las sagas las e evento, tras las proyecciones, pasa ese evento solo (`of(event)`) por cada saga y ejecuta los comandos que emite, uno a uno y esperando. Así heredan las garantías del bridge: orden, reintento y nada se pierde si la API se cae a mitad. -| Saga | Evento | Comandos | -| --------------------- | ------------------------------------------ | ----------------------------------------------------------------------------------------------------------- | -| `ProjectDeletionSaga` | `ProjectDeletedEvent` | `DeleteIssueCommand` por cada ticket y `DeleteReleaseCommand` por cada release del proyecto | -| | `IssueDeletedEvent` | `PurgeCommentCommand` por cada comentario y `DropLinksToIssueCommand` en los tickets que lo enlazaban | -| `ReleaseArchivedSaga` | `ReleaseArchivedEvent` | `DetachIssueFromReleaseCommand` para sus tickets sin terminar (los terminados se quedan) | -| `UserDeactivatedSaga` | `UserDeactivatedEvent` (contexto identity) | `UnassignIssueCommand` para sus tickets abiertos en todos los proyectos, como actor `system` («TaskStream») | +| Saga | Evento | Comandos | +| ---------------------- | ------------------------------------------ | ----------------------------------------------------------------------------------------------------------- | +| `ProjectDeletionSaga` | `ProjectDeletedEvent` | `DeleteIssueCommand` por cada ticket y `DeleteReleaseCommand` por cada release del proyecto | +| | `IssueDeletedEvent` | `PurgeCommentCommand` por cada comentario y `DropLinksToIssueCommand` en los tickets que lo enlazaban | +| `ReleaseArchivedSaga` | `ReleaseArchivedEvent` | `DetachIssueFromReleaseCommand` para sus tickets sin terminar (los terminados se quedan) | +| `CommentersFollowSaga` | `CommentAddedEvent` | `WatchIssueCommand`: quien comenta pasa a seguir el ticket | +| `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 c767eaa..d918ed2 100644 --- a/docs/03-api.md +++ b/docs/03-api.md @@ -162,7 +162,13 @@ ningún agregado, solo cada uno lee y descarta las suyas. | Alguien comenta | Quien lo tiene asignado y quien lo abrió | `COMMENTED` | | Te añaden a un proyecto | La persona añadida | `ADDED_TO_PROJECT` | -Nadie recibe aviso de lo que hizo él mismo, y cada persona recibe uno por evento (la primera razón gana). El id es +Nadie recibe aviso de lo que hizo él mismo, y cada persona recibe uno por evento: la primera razón de la tabla gana +(una mención antes que «comentó en tu ticket», y eso antes que «un ticket que sigues»). + +**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`). El id es `::`, así que reentregar un evento no duplica avisos. Llegan en vivo por la subscription `notificationAdded` y, si la instalación tiene `SMTP_URL`, también por correo a quien lo active (`email`, desactivado por defecto); un correo que falla se registra y no bloquea el stream. @@ -430,17 +436,17 @@ Lo que cada usuario puede ver o cambiar depende de su rol en cada proyecto, como Proyectos y tickets se pueden indicar por **id o por clave** (`WEB`, `WEB-12`). -| Recurso | Rutas | -| ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| Usuarios | `GET /api/users/me`, `GET /api/users?search=`, `GET /api/users/{id}` | -| Proyectos | `GET/POST /api/projects`, `GET/PATCH/DELETE /api/projects/{project}` (`DELETE …?confirm=CLAVE`), `PUT …/settings`, `…/board`, `…/stats`, `…/analytics?days=`, `…/activity`, `…/members` (POST, PATCH, DELETE) | -| Tickets | `GET /api/me/issues` (mi trabajo), `GET/POST /api/projects/{project}/issues`, `GET/PATCH /api/issues/{issue}`, `PUT …/status`, `PUT …/assignee`, `PUT …/release`, `POST/DELETE …/links`, `GET/POST …/comments`, `GET …/history` | -| Comentarios | `PATCH/DELETE /api/comments/{commentId}` | -| Avisos | `GET /api/me/notifications`, `POST /api/me/notifications/read`, `GET/PUT /api/me/notification-settings` | -| Adjuntos | `POST /api/issues/{issue}/attachments` (multipart: `file` y opcional `commentId`), `GET/DELETE /api/issues/{issue}/attachments/{id}` | -| Releases | `GET /api/me/releases` (próximas), `GET/POST /api/projects/{project}/releases`, `GET/PATCH /api/releases/{id}`, `…/progress`, `…/burnup`, `…/issues`, `POST …/publish`, `POST …/archive` | -| Explorar | `GET /api/explore/projects` (proyectos públicos, sin token) | -| Sistema | `GET /api/health`, `GET /api/auth/config`, `POST /api/auth/token`, `POST /api/webhooks/pireon` | +| Recurso | Rutas | +| ----------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Usuarios | `GET /api/users/me`, `GET /api/users?search=`, `GET /api/users/{id}` | +| Proyectos | `GET/POST /api/projects`, `GET/PATCH/DELETE /api/projects/{project}` (`DELETE …?confirm=CLAVE`), `PUT …/settings`, `…/board`, `…/stats`, `…/analytics?days=`, `…/activity`, `…/members` (POST, PATCH, DELETE) | +| Tickets | `GET /api/me/issues` (mi trabajo), `GET/POST /api/projects/{project}/issues`, `GET/PATCH /api/issues/{issue}`, `PUT …/status`, `PUT …/assignee`, `PUT …/release`, `PUT/DELETE …/watch`, `POST/DELETE …/links`, `GET/POST …/comments`, `GET …/history` | +| Comentarios | `PATCH/DELETE /api/comments/{commentId}` | +| Avisos | `GET /api/me/notifications`, `POST /api/me/notifications/read`, `GET/PUT /api/me/notification-settings` | +| Adjuntos | `POST /api/issues/{issue}/attachments` (multipart: `file` y opcional `commentId`), `GET/DELETE /api/issues/{issue}/attachments/{id}` | +| Releases | `GET /api/me/releases` (próximas), `GET/POST /api/projects/{project}/releases`, `GET/PATCH /api/releases/{id}`, `…/progress`, `…/burnup`, `…/issues`, `POST …/publish`, `POST …/archive` | +| Explorar | `GET /api/explore/projects` (proyectos públicos, sin token) | +| Sistema | `GET /api/health`, `GET /api/auth/config`, `POST /api/auth/token`, `POST /api/webhooks/pireon` | Los archivos van por REST porque GraphQL no está hecho para bytes; la lista de adjuntos de un ticket llega con el ticket (`attachments`, en GraphQL y en REST). La descarga sirve en línea solo imágenes, vídeo, audio, PDF y texto diff --git a/libs/api/tracker/application/src/lib/commands/command-handlers.ts b/libs/api/tracker/application/src/lib/commands/command-handlers.ts index a94ebc9..63a6c53 100644 --- a/libs/api/tracker/application/src/lib/commands/command-handlers.ts +++ b/libs/api/tracker/application/src/lib/commands/command-handlers.ts @@ -27,6 +27,7 @@ import { DetachIssueFromReleaseCommandHandler } from './detach-issue-from-releas import { UnassignIssueCommandHandler } from './unassign-issue.command-handler'; import { AttachFileCommandHandler } from './attach-file.command-handler'; import { DetachFileCommandHandler } from './detach-file.command-handler'; +import { WatchIssueCommandHandler } from './watch-issue.command-handler'; export const COMMAND_HANDLERS = [ CreateProjectCommandHandler, @@ -59,4 +60,5 @@ export const COMMAND_HANDLERS = [ UnassignIssueCommandHandler, AttachFileCommandHandler, DetachFileCommandHandler, + WatchIssueCommandHandler, ]; diff --git a/libs/api/tracker/application/src/lib/commands/commands.spec.ts b/libs/api/tracker/application/src/lib/commands/commands.spec.ts index 39a1796..d3dfad9 100644 --- a/libs/api/tracker/application/src/lib/commands/commands.spec.ts +++ b/libs/api/tracker/application/src/lib/commands/commands.spec.ts @@ -40,6 +40,7 @@ import { AddProjectMemberCommand } from './add-project-member.command'; import { AssignIssueCommand } from './assign-issue.command'; import { AttachFileCommand } from './attach-file.command'; import { DetachFileCommand } from './detach-file.command'; +import { WatchIssueCommand } from './watch-issue.command'; import { ChangeIssueStatusCommand } from './change-issue-status.command'; import { COMMAND_HANDLERS } from './command-handlers'; import { CommandResult } from './command-result'; @@ -258,6 +259,28 @@ describe('tracker commands (event sourced)', () => { }); }); + it('lets any member, viewers included, follow a ticket for themselves', async () => { + const { run, rehydrator } = await setup(); + const projectId = await projectWithTeam(run); + const issue = await run( + new CreateIssueCommand('qa', projectId, IssueType.BUG, 'Crash', {}), + ); + + await run(new WatchIssueCommand('pm', issue.id, 'pm')); + await run(new WatchIssueCommand('dev', issue.id, 'dev')); + await run(new WatchIssueCommand('dev', issue.id, 'dev', false)); + + expect((await rehydrator.rehydrate(issue.id, Issue)).watchers).toEqual([ + 'pm', + ]); + await expect( + run(new WatchIssueCommand('dev', issue.id, 'pm', false)), + ).rejects.toThrow(ForbiddenError); + await expect( + run(new WatchIssueCommand('gone', issue.id, 'gone')), + ).rejects.toThrow(ForbiddenError); + }); + it('does not let viewers modify tickets', async () => { const { run } = await setup(); const projectId = await projectWithTeam(run); diff --git a/libs/api/tracker/application/src/lib/commands/index.ts b/libs/api/tracker/application/src/lib/commands/index.ts index 94c5375..612b27e 100644 --- a/libs/api/tracker/application/src/lib/commands/index.ts +++ b/libs/api/tracker/application/src/lib/commands/index.ts @@ -28,3 +28,4 @@ export * from './detach-issue-from-release.command'; export * from './unassign-issue.command'; export * from './attach-file.command'; export * from './detach-file.command'; +export * from './watch-issue.command'; diff --git a/libs/api/tracker/application/src/lib/commands/watch-issue.command-handler.ts b/libs/api/tracker/application/src/lib/commands/watch-issue.command-handler.ts new file mode 100644 index 0000000..2e5710e --- /dev/null +++ b/libs/api/tracker/application/src/lib/commands/watch-issue.command-handler.ts @@ -0,0 +1,33 @@ +import { CommandHandler, ICommandHandler } from '@nestjs/cqrs'; +import { ForbiddenError } from '@task-stream/api/shared/domain'; +import { CommandResult, ack } from './command-result'; +import { WatchIssueCommand } from './watch-issue.command'; +import { WriteAccess } from './write-access'; + +/** + * Following is for members, viewers included: reading a ticket is enough to + * want to hear about it. People only follow or unfollow for themselves. + */ +@CommandHandler(WatchIssueCommand) +export class WatchIssueCommandHandler + implements ICommandHandler +{ + constructor(private readonly access: WriteAccess) {} + + async execute(command: WatchIssueCommand): Promise { + const issue = await this.access.issue(command.issueId); + const project = await this.access.project(issue.projectId); + if (command.actorId !== command.userId) { + throw new ForbiddenError('You can only follow tickets yourself'); + } + if (!project.isMember(command.userId)) { + throw new ForbiddenError( + `You are not a member of project ${project.key}`, + ); + } + if (command.watching) issue.watch(command.actorId, command.userId); + else issue.unwatch(command.actorId, command.userId); + await issue.commitAndWait(); + return ack(issue); + } +} diff --git a/libs/api/tracker/application/src/lib/commands/watch-issue.command.ts b/libs/api/tracker/application/src/lib/commands/watch-issue.command.ts new file mode 100644 index 0000000..b0fac19 --- /dev/null +++ b/libs/api/tracker/application/src/lib/commands/watch-issue.command.ts @@ -0,0 +1,14 @@ +import { Command } from '@nestjs/cqrs'; +import { CommandResult } from './command-result'; + +/** `userId` starts (`watching`) or stops following a ticket. */ +export class WatchIssueCommand extends Command { + constructor( + public readonly actorId: string, + public readonly issueId: string, + public readonly userId: string, + public readonly watching = true, + ) { + super(); + } +} diff --git a/libs/api/tracker/application/src/lib/event-handlers/event-handlers.ts b/libs/api/tracker/application/src/lib/event-handlers/event-handlers.ts index 66d558f..6ebf4ed 100644 --- a/libs/api/tracker/application/src/lib/event-handlers/event-handlers.ts +++ b/libs/api/tracker/application/src/lib/event-handlers/event-handlers.ts @@ -27,6 +27,7 @@ import { ReleaseDeletedEventHandler } from './release-deleted.event-handler'; import { IssueAttachmentAddedEventHandler } from './issue-attachment-added.event-handler'; import { IssueAttachmentRemovedEventHandler } from './issue-attachment-removed.event-handler'; import { AttachmentCleanupEventHandler } from './attachment-cleanup.event-handler'; +import { IssueWatchersEventHandler } from './issue-watchers.event-handler'; /** Read-model projections (MongoDB) + realtime, fed by the EventsBridge. */ export const EVENT_HANDLERS = [ @@ -58,6 +59,7 @@ export const EVENT_HANDLERS = [ IssueAttachmentAddedEventHandler, IssueAttachmentRemovedEventHandler, AttachmentCleanupEventHandler, + IssueWatchersEventHandler, ]; export { ReadModelNotifier }; diff --git a/libs/api/tracker/application/src/lib/event-handlers/issue-watchers.event-handler.ts b/libs/api/tracker/application/src/lib/event-handlers/issue-watchers.event-handler.ts new file mode 100644 index 0000000..eedf30e --- /dev/null +++ b/libs/api/tracker/application/src/lib/event-handlers/issue-watchers.event-handler.ts @@ -0,0 +1,40 @@ +import { EventsHandler, IEventHandler } from '@nestjs/cqrs'; +import { + IssueUnwatchedEvent, + IssueWatchedEvent, +} from '@task-stream/api/tracker/domain'; +import { UpsertMaterializedIssueRepository } from '../ports/upsert-materialized-issue.repository'; +import { UserDirectory } from '../ports/user-directory'; +import { ReadModelNotifier, versionOf } from './read-model-notifier'; + +@EventsHandler(IssueWatchedEvent, IssueUnwatchedEvent) +export class IssueWatchersEventHandler + implements IEventHandler +{ + constructor( + private readonly upsertMaterializedIssueRepository: UpsertMaterializedIssueRepository, + private readonly users: UserDirectory, + private readonly notifier: ReadModelNotifier, + ) {} + + async handle(event: IssueWatchedEvent | IssueUnwatchedEvent) { + // Following is not a change to the ticket: `updatedAt` stays. + await this.upsertMaterializedIssueRepository.upsert({ + id: event.aggregateId, + version: versionOf(event), + }); + if (event instanceof IssueWatchedEvent) { + const users = await this.users.refs([event.userId]); + await this.upsertMaterializedIssueRepository.addWatcher( + event.aggregateId, + users.of(event.userId), + ); + } else { + await this.upsertMaterializedIssueRepository.removeWatcher( + event.aggregateId, + event.userId, + ); + } + await this.notifier.issue(event.aggregateId, event); + } +} diff --git a/libs/api/tracker/application/src/lib/issues.service.ts b/libs/api/tracker/application/src/lib/issues.service.ts index 5112959..d00ba20 100644 --- a/libs/api/tracker/application/src/lib/issues.service.ts +++ b/libs/api/tracker/application/src/lib/issues.service.ts @@ -12,6 +12,7 @@ import { IncomingFile, } from './commands/attach-file.command'; import { DetachFileCommand } from './commands/detach-file.command'; +import { WatchIssueCommand } from './commands/watch-issue.command'; import { ChangeIssueStatusCommand } from './commands/change-issue-status.command'; import { CommandResult } from './commands/command-result'; import { CreateIssueCommand } from './commands/create-issue.command'; @@ -157,6 +158,17 @@ export class IssuesService { ); } + /** The actor follows (or stops following) the ticket. */ + watch( + actorId: string, + issueId: string, + watching: boolean, + ): Promise { + return this.commandBus.execute( + new WatchIssueCommand(actorId, issueId, actorId, watching), + ); + } + /** A ticket's file, with its bytes, for whoever can read the ticket. */ download( viewerId: string | null, diff --git a/libs/api/tracker/application/src/lib/notifications/notifications.event-handler.ts b/libs/api/tracker/application/src/lib/notifications/notifications.event-handler.ts index 9f1c708..6afb56b 100644 --- a/libs/api/tracker/application/src/lib/notifications/notifications.event-handler.ts +++ b/libs/api/tracker/application/src/lib/notifications/notifications.event-handler.ts @@ -4,10 +4,12 @@ import { IssueAssignedEvent, IssueCreatedEvent, IssueStatusChangedEvent, + mentionedUserIds, NotificationKind, ProjectMemberAddedEvent, StatusCategory, TrackerEvent, + withoutMentionMarkup, } from '@task-stream/api/tracker/domain'; import { FindIssuesRepository } from '../ports/find-issues.repository'; import { FindProjectsRepository } from '../ports/find-projects.repository'; @@ -17,14 +19,16 @@ import { NotificationDraft, Notifier } from './notifier'; /* Read by people, like the activity feed: Spanish. */ const excerpt = (text: string, max = 120) => { - const flat = text.replace(/\s+/g, ' ').trim(); + const flat = withoutMentionMarkup(text).replace(/\s+/g, ' ').trim(); return flat.length > max ? `${flat.slice(0, max - 1)}…` : flat; }; /** * Who is told what, for the events that concern someone in particular: - * whoever a ticket is assigned to, whoever reported or works on it when it - * moves or gets a comment, whoever joins a project. + * whoever is mentioned, whoever a ticket is assigned to, whoever reported or + * works on it when it moves or gets a comment, whoever follows it, whoever + * joins a project. The order is the order of importance: each person gets + * one notification per event, for the first reason that applies. */ @EventsHandler( IssueCreatedEvent, @@ -81,6 +85,9 @@ export class NotificationsEventHandler implements IEventHandler { const assigneeId = 'assigneeId' in issue ? issue.assigneeId : (issue.assignee?.id ?? null); const ticket = `${issue.key}: ${issue.title}`; + const watcherIds = + 'watchers' in issue ? issue.watchers.map((w) => w.id) : []; + const memberIds = new Set(project.members.map((m) => m.user.id)); const drafts: NotificationDraft[] = []; if (event instanceof IssueCreatedEvent && assigneeId) { @@ -90,12 +97,26 @@ export class NotificationsEventHandler implements IEventHandler { summary: `${actor} te asignó ${ticket}`, }); } - if (event instanceof IssueAssignedEvent && event.assigneeId) { - drafts.push({ - userId: event.assigneeId, - kind: NotificationKind.ASSIGNED, - summary: `${actor} te asignó ${ticket}`, - }); + if (event instanceof IssueAssignedEvent) { + const assignee = event.assigneeId + ? (await this.users.refs([event.assigneeId])).of(event.assigneeId).name + : null; + if (event.assigneeId) { + drafts.push({ + userId: event.assigneeId, + kind: NotificationKind.ASSIGNED, + summary: `${actor} te asignó ${ticket}`, + }); + } + for (const userId of watcherIds) { + drafts.push({ + userId, + kind: NotificationKind.WATCHED, + summary: assignee + ? `${actor} asignó ${ticket} a ${assignee}` + : `${actor} dejó sin asignar ${ticket}`, + }); + } } if (event instanceof IssueStatusChangedEvent) { const to = event.meta?.toName ?? event.to; @@ -105,6 +126,9 @@ export class NotificationsEventHandler implements IEventHandler { event.meta?.toCategory !== StatusCategory.QA && event.meta?.toCategory !== StatusCategory.DONE; const reason = event.reason ? `: ${excerpt(event.reason)}` : ''; + const summary = failedQa + ? `${actor} devolvió ${ticket} desde QA${reason}` + : `${actor} movió ${ticket} a ${to}${reason}`; for (const userId of [assigneeId, reporterId]) { if (!userId) continue; drafts.push({ @@ -112,19 +136,37 @@ export class NotificationsEventHandler implements IEventHandler { kind: failedQa ? NotificationKind.QA_FAILED : NotificationKind.STATUS_CHANGED, - summary: failedQa - ? `${actor} devolvió ${ticket} desde QA${reason}` - : `${actor} movió ${ticket} a ${to}${reason}`, + summary, }); } + for (const userId of watcherIds) { + drafts.push({ userId, kind: NotificationKind.WATCHED, summary }); + } } if (event instanceof CommentAddedEvent) { + const said = `«${excerpt(event.comment.body)}»`; + // Only members: a mention cannot reach someone who cannot read the ticket. + for (const userId of mentionedUserIds(event.comment.body)) { + if (!memberIds.has(userId)) continue; + drafts.push({ + userId, + kind: NotificationKind.MENTIONED, + summary: `${actor} te mencionó en ${issue.key}: ${said}`, + }); + } for (const userId of [assigneeId, reporterId]) { if (!userId) continue; drafts.push({ userId, kind: NotificationKind.COMMENTED, - summary: `${actor} comentó en ${issue.key}: «${excerpt(event.comment.body)}»`, + summary: `${actor} comentó en ${issue.key}: ${said}`, + }); + } + for (const userId of watcherIds) { + drafts.push({ + userId, + kind: NotificationKind.WATCHED, + summary: `${actor} comentó en ${issue.key}, que sigues: ${said}`, }); } } diff --git a/libs/api/tracker/application/src/lib/notifications/notifications.spec.ts b/libs/api/tracker/application/src/lib/notifications/notifications.spec.ts index 8fb1eef..e2cffba 100644 --- a/libs/api/tracker/application/src/lib/notifications/notifications.spec.ts +++ b/libs/api/tracker/application/src/lib/notifications/notifications.spec.ts @@ -54,13 +54,21 @@ function setup(mailEnabled = false) { const repo = new InMemoryNotifications(); const published: NotificationReadModel[] = []; const mailed: string[] = []; - const users = new FakeUserDirectory().add('ana').add('bob').add('qa'); + const users = new FakeUserDirectory() + .add('ana') + .add('bob') + .add('qa') + .add('pm'); const issue = { id: 'i-1', key: 'WEB-1', title: 'Login roto', reporter: { id: 'qa', name: 'QA' }, assignee: { id: 'bob', name: 'Bob' }, + watchers: [ + { id: 'pm', name: 'PM' }, + { id: 'bob', name: 'Bob' }, + ], }; const notifier = new Notifier( repo, @@ -81,7 +89,15 @@ function setup(mailEnabled = false) { notifier, { findById: async () => issue } as unknown as FindIssuesRepository, { - findById: async () => ({ id: 'p-1', key: 'WEB', name: 'Web' }), + findById: async () => ({ + id: 'p-1', + key: 'WEB', + name: 'Web', + members: ['ana', 'bob', 'qa', 'pm'].map((id) => ({ + user: { id, name: id }, + role: ProjectRole.MEMBER, + })), + }), } as unknown as FindProjectsRepository, users, ); @@ -100,8 +116,12 @@ describe('notifications', () => { expect(repo.all.map((n) => [n.userId, n.kind])).toEqual([ ['bob', NotificationKind.ASSIGNED], + // Bob follows the ticket too, but is told once: as the assignee. + ['pm', NotificationKind.WATCHED], + ['pm', NotificationKind.WATCHED], + ['bob', NotificationKind.WATCHED], ]); - expect(published).toHaveLength(1); + expect(published).toHaveLength(4); expect(repo.all[0].summary).toContain('WEB-1'); }); @@ -129,6 +149,7 @@ describe('notifications', () => { expect(repo.all.map((n) => [n.userId, n.kind])).toEqual([ ['bob', NotificationKind.QA_FAILED], ['qa', NotificationKind.QA_FAILED], + ['pm', NotificationKind.WATCHED], ]); expect(repo.all[0].summary).toContain('Falla en Safari'); }); @@ -151,7 +172,32 @@ describe('notifications', () => { await handler.handle(comment); await handler.handle(comment); - expect(repo.all.map((n) => n.userId)).toEqual(['qa']); + expect(repo.all.map((n) => n.userId)).toEqual(['qa', 'pm']); + }); + + it('tells a mentioned member first, and nobody outside the project', async () => { + const { handler, repo } = setup(); + await handler.handle( + at( + new CommentAddedEvent('bob', { + id: 'c-2', + issueId: 'i-1', + projectId: 'p-1', + authorId: 'bob', + body: '@[PM](pm) mira esto, y @[Fuera](stranger) también', + deleted: false, + createdAt: '2026-10-08T10:00:00.000Z', + editedAt: null, + }), + 1, + ), + ); + + expect(repo.all.map((n) => [n.userId, n.kind])).toEqual([ + ['pm', NotificationKind.MENTIONED], + ['qa', NotificationKind.COMMENTED], + ]); + expect(repo.all[0].summary).toContain('«@PM mira esto'); }); it('copies by e-mail only to whoever asked for it, when mail is configured', async () => { diff --git a/libs/api/tracker/application/src/lib/ports/upsert-materialized-issue.repository.ts b/libs/api/tracker/application/src/lib/ports/upsert-materialized-issue.repository.ts index 3982cd4..a8dd4fa 100644 --- a/libs/api/tracker/application/src/lib/ports/upsert-materialized-issue.repository.ts +++ b/libs/api/tracker/application/src/lib/ports/upsert-materialized-issue.repository.ts @@ -3,13 +3,19 @@ import { IssueLinkReadModel, IssueReadModel, StatusCategory, + UserRefReadModel, } from '@task-stream/api/tracker/domain'; export abstract class UpsertMaterializedIssueRepository { /** Applied only if `version` is newer than the stored one (idempotent). */ abstract upsert( issue: Pick & - Partial>, + Partial< + Omit< + IssueReadModel, + 'links' | 'commentCount' | 'attachments' | 'watchers' + > + >, ): Promise; /** Adds a relation to an issue (idempotent). */ abstract addLink(issueId: string, link: IssueLinkReadModel): Promise; @@ -26,6 +32,9 @@ export abstract class UpsertMaterializedIssueRepository { issueId: string, attachmentId: string, ): Promise; + /** Adds a follower (idempotent). */ + abstract addWatcher(issueId: string, user: UserRefReadModel): Promise; + abstract removeWatcher(issueId: string, userId: string): Promise; abstract setCommentCount( issueId: string, commentCount: number, diff --git a/libs/api/tracker/application/src/lib/sagas/commenters-follow.saga.spec.ts b/libs/api/tracker/application/src/lib/sagas/commenters-follow.saga.spec.ts new file mode 100644 index 0000000..31c0fba --- /dev/null +++ b/libs/api/tracker/application/src/lib/sagas/commenters-follow.saga.spec.ts @@ -0,0 +1,25 @@ +import { CommentAddedEvent } from '@task-stream/api/tracker/domain'; +import { lastValueFrom, of, toArray } from 'rxjs'; +import { WatchIssueCommand } from '../commands/watch-issue.command'; +import { CommentersFollowSaga } from './commenters-follow.saga'; + +describe('CommentersFollowSaga', () => { + it('makes whoever comments follow the ticket', async () => { + const event = new CommentAddedEvent('ana', { + id: 'c-1', + issueId: 'i-1', + projectId: 'p-1', + authorId: 'ana', + body: 'Lo miro', + deleted: false, + createdAt: '2026-10-08T10:00:00.000Z', + editedAt: null, + }); + + const commands = await lastValueFrom( + new CommentersFollowSaga().commented(of(event)).pipe(toArray()), + ); + + expect(commands).toEqual([new WatchIssueCommand('ana', 'i-1', 'ana')]); + }); +}); diff --git a/libs/api/tracker/application/src/lib/sagas/commenters-follow.saga.ts b/libs/api/tracker/application/src/lib/sagas/commenters-follow.saga.ts new file mode 100644 index 0000000..8b27f22 --- /dev/null +++ b/libs/api/tracker/application/src/lib/sagas/commenters-follow.saga.ts @@ -0,0 +1,27 @@ +import { Injectable } from '@nestjs/common'; +import { ICommand, IEvent, ofType, Saga } from '@nestjs/cqrs'; +import { CommentAddedEvent } from '@task-stream/api/tracker/domain'; +import { map, Observable } from 'rxjs'; +import { WatchIssueCommand } from '../commands/watch-issue.command'; + +/** + * Whoever comments on a ticket follows it from then on: they took part in the + * conversation, so they will want to hear how it ends. Unfollowing stays + * theirs to do. (Idempotent: following twice changes nothing.) + */ +@Injectable() +export class CommentersFollowSaga { + @Saga() + commented = (events$: Observable): Observable => + events$.pipe( + ofType(CommentAddedEvent), + map( + (event) => + new WatchIssueCommand( + event.comment.authorId, + event.comment.issueId, + event.comment.authorId, + ), + ), + ); +} diff --git a/libs/api/tracker/application/src/lib/sagas/sagas.ts b/libs/api/tracker/application/src/lib/sagas/sagas.ts index 7c9f4c0..ac4a1ce 100644 --- a/libs/api/tracker/application/src/lib/sagas/sagas.ts +++ b/libs/api/tracker/application/src/lib/sagas/sagas.ts @@ -1,10 +1,12 @@ import { ProjectDeletionSaga } from './project-deletion.saga'; import { ReleaseArchivedSaga } from './release-archived.saga'; import { UserDeactivatedSaga } from './user-deactivated.saga'; +import { CommentersFollowSaga } from './commenters-follow.saga'; /** Reactions to events that change other aggregates (run by the events bridge). */ export const SAGAS = [ ProjectDeletionSaga, ReleaseArchivedSaga, UserDeactivatedSaga, + CommentersFollowSaga, ]; diff --git a/libs/api/tracker/domain/src/index.ts b/libs/api/tracker/domain/src/index.ts index 9ed5c2d..5fd0438 100644 --- a/libs/api/tracker/domain/src/index.ts +++ b/libs/api/tracker/domain/src/index.ts @@ -3,6 +3,7 @@ export * from './lib/project/project-role'; export * from './lib/issue/issue'; export * from './lib/issue/issue.enums'; export * from './lib/issue/issue-attachment'; +export * from './lib/issue/mentions'; export * from './lib/project/project-settings'; export * from './lib/comment/comment'; export * from './lib/release/release'; diff --git a/libs/api/tracker/domain/src/lib/events/index.ts b/libs/api/tracker/domain/src/lib/events/index.ts index 0b11287..caa17be 100644 --- a/libs/api/tracker/domain/src/lib/events/index.ts +++ b/libs/api/tracker/domain/src/lib/events/index.ts @@ -24,3 +24,5 @@ export * from './issue-deleted.event'; export * from './release-deleted.event'; export * from './issue-attachment-added.event'; export * from './issue-attachment-removed.event'; +export * from './issue-watched.event'; +export * from './issue-unwatched.event'; diff --git a/libs/api/tracker/domain/src/lib/events/issue-unwatched.event.ts b/libs/api/tracker/domain/src/lib/events/issue-unwatched.event.ts new file mode 100644 index 0000000..49d44a7 --- /dev/null +++ b/libs/api/tracker/domain/src/lib/events/issue-unwatched.event.ts @@ -0,0 +1,15 @@ +import { AutowiredEvent } from '@task-stream/api/shared/domain'; +import { TrackerEvent } from './tracker.event'; + +/** Someone stopped following a ticket. */ +@AutowiredEvent +export class IssueUnwatchedEvent extends TrackerEvent { + constructor( + actorId: string, + issueId: string, + projectId: string, + readonly userId: string, + ) { + super(actorId, issueId, projectId); + } +} diff --git a/libs/api/tracker/domain/src/lib/events/issue-watched.event.ts b/libs/api/tracker/domain/src/lib/events/issue-watched.event.ts new file mode 100644 index 0000000..741a4f9 --- /dev/null +++ b/libs/api/tracker/domain/src/lib/events/issue-watched.event.ts @@ -0,0 +1,15 @@ +import { AutowiredEvent } from '@task-stream/api/shared/domain'; +import { TrackerEvent } from './tracker.event'; + +/** Someone started following a ticket. */ +@AutowiredEvent +export class IssueWatchedEvent extends TrackerEvent { + constructor( + actorId: string, + issueId: string, + projectId: string, + readonly userId: string, + ) { + super(actorId, issueId, projectId); + } +} diff --git a/libs/api/tracker/domain/src/lib/issue/issue.spec.ts b/libs/api/tracker/domain/src/lib/issue/issue.spec.ts index 97ea0b9..a7c8d63 100644 --- a/libs/api/tracker/domain/src/lib/issue/issue.spec.ts +++ b/libs/api/tracker/domain/src/lib/issue/issue.spec.ts @@ -8,6 +8,7 @@ import { IssueStatusChangedEvent } from '../events/issue-status-changed.event'; import { IssueFactory } from '../factories/issue.factory'; import { replay } from '../testing'; import { Issue } from './issue'; +import { mentionedUserIds, withoutMentionMarkup } from './mentions'; import { IssueLinkType, IssueStatus, IssueType } from './issue.enums'; const factory = new IssueFactory(); @@ -206,4 +207,31 @@ describe('Issue', () => { expect(rebuilt.attachments.map((a) => a.id)).toEqual(['a-2']); }); }); + + describe('followers', () => { + it('follows and unfollows once, and survives a replay', () => { + const issue = newIssue(); + issue.watch('u-dev', 'u-dev'); + issue.watch('u-dev', 'u-dev'); + issue.watch('u-pm', 'u-pm'); + issue.unwatch('u-dev', 'u-dev'); + issue.unwatch('u-dev', 'u-dev'); + + expect(issue.watchers).toEqual(['u-pm']); + expect(replay(issue, Issue).rebuilt.watchers).toEqual(['u-pm']); + expect(issue.getUncommittedEvents()).toHaveLength(4); + }); + }); + + describe('mentions', () => { + it('reads who a text mentions, once each', () => { + const text = + 'Hola @[Ana García](u-ana) y @[Bob](u-bob), @[Ana García](u-ana) otra vez. @sinmarca'; + + expect(mentionedUserIds(text)).toEqual(['u-ana', 'u-bob']); + expect(withoutMentionMarkup(text)).toBe( + 'Hola @Ana García y @Bob, @Ana García otra vez. @sinmarca', + ); + }); + }); }); diff --git a/libs/api/tracker/domain/src/lib/issue/issue.ts b/libs/api/tracker/domain/src/lib/issue/issue.ts index 75de394..5f7920c 100644 --- a/libs/api/tracker/domain/src/lib/issue/issue.ts +++ b/libs/api/tracker/domain/src/lib/issue/issue.ts @@ -14,6 +14,8 @@ import { IssueReleaseChangedEvent } from '../events/issue-release-changed.event' import { IssueStatusChangedEvent } from '../events/issue-status-changed.event'; import { IssueUnlinkedEvent } from '../events/issue-unlinked.event'; import { IssueUpdatedEvent } from '../events/issue-updated.event'; +import { IssueUnwatchedEvent } from '../events/issue-unwatched.event'; +import { IssueWatchedEvent } from '../events/issue-watched.event'; import { ProjectWorkflow } from '../project/project-settings'; import { IssueAttachment, @@ -91,6 +93,8 @@ export class Issue extends VersionedAggregateRoot implements IssueProps { public dueDate: string | null = null; public links: IssueLink[] = []; public attachments: IssueAttachment[] = []; + /** People who asked to be told about this ticket. */ + public watchers: string[] = []; public createdAt!: string; public updatedAt!: string; public deleted = false; @@ -262,6 +266,20 @@ export class Issue extends VersionedAggregateRoot implements IssueProps { ); } + /** `userId` starts following the ticket. Idempotent. */ + watch(actorId: string, userId: string): void { + if (this.watchers.includes(userId)) return; + this.apply(new IssueWatchedEvent(actorId, this.id, this.projectId, userId)); + } + + /** `userId` stops following the ticket. Idempotent. */ + unwatch(actorId: string, userId: string): void { + if (!this.watchers.includes(userId)) return; + this.apply( + new IssueUnwatchedEvent(actorId, this.id, this.projectId, userId), + ); + } + /** Deletes the ticket (its project was deleted). Idempotent. */ delete(actorId: string): void { if (this.deleted) return; @@ -334,6 +352,14 @@ export class Issue extends VersionedAggregateRoot implements IssueProps { this.updatedAt = event.occurredAt; } + onIssueWatchedEvent(event: IssueWatchedEvent): void { + this.watchers = [...this.watchers, event.userId]; + } + + onIssueUnwatchedEvent(event: IssueUnwatchedEvent): void { + this.watchers = this.watchers.filter((id) => id !== event.userId); + } + onIssueDeletedEvent(): void { this.deleted = true; } diff --git a/libs/api/tracker/domain/src/lib/issue/mentions.ts b/libs/api/tracker/domain/src/lib/issue/mentions.ts new file mode 100644 index 0000000..432e142 --- /dev/null +++ b/libs/api/tracker/domain/src/lib/issue/mentions.ts @@ -0,0 +1,17 @@ +/** + * Mentions are written `@[Ana García]()`: the name to show and who + * it is, so renaming someone never breaks an old comment. The web app inserts + * them from a list of the project's members. + */ +const MENTION = /@\[([^\]\n]{1,80})\]\(([\w-]{1,64})\)/g; + +/** Who a text mentions, once each, in order. */ +export function mentionedUserIds(text: string | null | undefined): string[] { + if (!text) return []; + return [...new Set([...text.matchAll(MENTION)].map((m) => m[2]))]; +} + +/** The text as a person reads it: `@Ana García` instead of the markup. */ +export function withoutMentionMarkup(text: string): string { + return text.replace(MENTION, (_, name: string) => `@${name}`); +} diff --git a/libs/api/tracker/domain/src/lib/read-models/issue.read-model.ts b/libs/api/tracker/domain/src/lib/read-models/issue.read-model.ts index e289dc1..1dd3f2c 100644 --- a/libs/api/tracker/domain/src/lib/read-models/issue.read-model.ts +++ b/libs/api/tracker/domain/src/lib/read-models/issue.read-model.ts @@ -47,6 +47,8 @@ export class IssueReadModel { dueDate!: string | null; links!: IssueLinkReadModel[]; attachments!: AttachmentReadModel[]; + /** People following the ticket. */ + watchers!: UserRefReadModel[]; commentCount!: number; createdAt!: string; updatedAt!: string; diff --git a/libs/api/tracker/infrastructure/src/lib/mongo/read-model.schemas.ts b/libs/api/tracker/infrastructure/src/lib/mongo/read-model.schemas.ts index 12b0fcb..f7f80c4 100644 --- a/libs/api/tracker/infrastructure/src/lib/mongo/read-model.schemas.ts +++ b/libs/api/tracker/infrastructure/src/lib/mongo/read-model.schemas.ts @@ -77,6 +77,7 @@ export const IssueSchema = new Schema( release: { _id: false, id: String, name: String }, outwardLinks: [link], inwardLinks: [link], + watchers: [userRef], attachments: [ { _id: false, @@ -97,6 +98,7 @@ export const IssueSchema = new Schema( IssueSchema.index({ projectId: 1, status: 1, updatedAt: -1 }); IssueSchema.index({ projectId: 1, statusCategory: 1 }); IssueSchema.index({ 'assignee.id': 1 }); +IssueSchema.index({ 'watchers.id': 1 }); IssueSchema.index({ 'release.id': 1 }); IssueSchema.index({ title: 'text', description: 'text', key: 'text' }); @@ -179,5 +181,6 @@ export function issueFromDoc(doc: IssueDoc): IssueReadModel { links: [...(outwardLinks ?? []), ...(inwardLinks ?? [])], // Tickets projected before attachments existed have none. attachments: rest.attachments ?? [], + watchers: rest.watchers ?? [], }; } diff --git a/libs/api/tracker/infrastructure/src/lib/mongo/upsert-materialized.repositories.ts b/libs/api/tracker/infrastructure/src/lib/mongo/upsert-materialized.repositories.ts index 634316d..95e912d 100644 --- a/libs/api/tracker/infrastructure/src/lib/mongo/upsert-materialized.repositories.ts +++ b/libs/api/tracker/infrastructure/src/lib/mongo/upsert-materialized.repositories.ts @@ -18,6 +18,7 @@ import { ProjectReadModel, ReleaseReadModel, StatusCategory, + UserRefReadModel, } from '@task-stream/api/tracker/domain'; import { Model } from 'mongoose'; import { @@ -100,15 +101,35 @@ export class MongoUpsertMaterializedIssueRepository extends UpsertMaterializedIs version, ...fields }: Pick & - Partial>) { + Partial< + Omit< + IssueReadModel, + 'links' | 'commentCount' | 'attachments' | 'watchers' + > + >) { await upsertIfNewer(this.issues, id, version, fields, { outwardLinks: [], inwardLinks: [], attachments: [], + watchers: [], commentCount: 0, }); } + async addWatcher(issueId: string, user: UserRefReadModel) { + await this.issues.updateOne( + { _id: issueId, 'watchers.id': { $ne: user.id } }, + { $push: { watchers: user } }, + ); + } + + async removeWatcher(issueId: string, userId: string) { + await this.issues.updateOne( + { _id: issueId }, + { $pull: { watchers: { id: userId } } }, + ); + } + async addAttachment(issueId: string, attachment: AttachmentReadModel) { await this.issues.updateOne( { _id: issueId, 'attachments.id': { $ne: attachment.id } }, diff --git a/libs/api/tracker/presenters/src/lib/graphql/issue.resolver.ts b/libs/api/tracker/presenters/src/lib/graphql/issue.resolver.ts index df916a0..fac82f9 100644 --- a/libs/api/tracker/presenters/src/lib/graphql/issue.resolver.ts +++ b/libs/api/tracker/presenters/src/lib/graphql/issue.resolver.ts @@ -183,6 +183,25 @@ export class IssueResolver { ); } + @Mutation(() => CommandResultGql, { + description: + 'Follow a ticket: be told when it moves, is reassigned or gets a comment', + }) + watchIssue( + @CurrentUser() me: User, + @Args('issueId', { type: () => ID }) issueId: string, + ) { + return this.issuesService.watch(me.id, issueId, true); + } + + @Mutation(() => CommandResultGql, { description: 'Stop following a ticket' }) + unwatchIssue( + @CurrentUser() me: User, + @Args('issueId', { type: () => ID }) issueId: string, + ) { + return this.issuesService.watch(me.id, issueId, false); + } + @Mutation(() => CommandResultGql, { description: 'Remove a file from a ticket (whoever uploaded it, or an owner/admin). Upload with the REST API.', diff --git a/libs/api/tracker/presenters/src/lib/graphql/types.ts b/libs/api/tracker/presenters/src/lib/graphql/types.ts index d6ed4e4..593ba85 100644 --- a/libs/api/tracker/presenters/src/lib/graphql/types.ts +++ b/libs/api/tracker/presenters/src/lib/graphql/types.ts @@ -143,6 +143,8 @@ export class IssueGql { @Field(() => String, { nullable: true }) dueDate!: string | null; @Field(() => [IssueLinkGql]) links!: IssueLinkGql[]; @Field(() => [AttachmentGql]) attachments!: AttachmentGql[]; + @Field(() => [UserRefGql], { description: 'People following the ticket' }) + watchers!: UserRefGql[]; @Field(() => Int) commentCount!: number; @Field() createdAt!: string; @Field() updatedAt!: string; diff --git a/libs/api/tracker/presenters/src/lib/http/dto/responses.dto.ts b/libs/api/tracker/presenters/src/lib/http/dto/responses.dto.ts index 6a0f022..234fb85 100644 --- a/libs/api/tracker/presenters/src/lib/http/dto/responses.dto.ts +++ b/libs/api/tracker/presenters/src/lib/http/dto/responses.dto.ts @@ -159,6 +159,8 @@ export class IssueDto { dueDate!: string | null; @ApiProperty({ type: [IssueLinkDto] }) links!: IssueLinkDto[]; @ApiProperty({ type: [AttachmentDto] }) attachments!: AttachmentDto[]; + @ApiProperty({ type: [UserRefDto], description: 'People following it' }) + watchers!: UserRefDto[]; @ApiProperty() commentCount!: number; @ApiProperty({ format: 'date-time' }) createdAt!: string; @ApiProperty({ format: 'date-time' }) updatedAt!: string; diff --git a/libs/api/tracker/presenters/src/lib/http/issues.controller.ts b/libs/api/tracker/presenters/src/lib/http/issues.controller.ts index 807712c..a257b08 100644 --- a/libs/api/tracker/presenters/src/lib/http/issues.controller.ts +++ b/libs/api/tracker/presenters/src/lib/http/issues.controller.ts @@ -77,6 +77,36 @@ export class IssuesController { private readonly refs: Refs, ) {} + @Put('issues/:issue/watch') + @ISSUE + @ApiOperation({ + summary: 'Follow a ticket (members, viewers included)', + description: + 'Followers are told when it moves, is reassigned or gets a comment. Commenting follows it too.', + }) + @ApiOkResponse({ type: CommandResultDto }) + @ApiCommandErrors() + async watch(@CurrentUser() me: User, @Param('issue') issue: string) { + return this.issues.watch( + me.id, + await this.refs.issueId(me.id, issue), + true, + ); + } + + @Delete('issues/:issue/watch') + @ISSUE + @ApiOperation({ summary: 'Stop following a ticket' }) + @ApiOkResponse({ type: CommandResultDto }) + @ApiCommandErrors() + async unwatch(@CurrentUser() me: User, @Param('issue') issue: string) { + return this.issues.watch( + me.id, + await this.refs.issueId(me.id, issue), + false, + ); + } + @Get('me/issues') @ApiOperation({ summary: 'My work: tickets across all my projects', diff --git a/libs/web/tracker/application/src/lib/events/issue.events.ts b/libs/web/tracker/application/src/lib/events/issue.events.ts index 76e2f5b..b2a6d6b 100644 --- a/libs/web/tracker/application/src/lib/events/issue.events.ts +++ b/libs/web/tracker/application/src/lib/events/issue.events.ts @@ -35,6 +35,7 @@ export const issuePageEvents = eventGroup({ /** Uploaded under the new comment once it exists. */ files?: File[]; }>(), + watchRequested: type<{ issueId: string; watching: boolean }>(), attachRequested: type<{ issueId: string; files: File[]; diff --git a/libs/web/tracker/application/src/lib/facades/issue.facade.ts b/libs/web/tracker/application/src/lib/facades/issue.facade.ts index cd0384e..bb77ca0 100644 --- a/libs/web/tracker/application/src/lib/facades/issue.facade.ts +++ b/libs/web/tracker/application/src/lib/facades/issue.facade.ts @@ -30,6 +30,8 @@ export class IssueFacade { readonly error = this.store.error; /** Signed-in user's id (authors can edit their comments). */ readonly me = this.store.me; + /** Whether the signed-in person follows the open ticket. */ + readonly watching = this.store.watching; /** Loads the ticket (by id or key, e.g. BE-3) and follows its changes. */ open(idOrKey: string): void { @@ -85,6 +87,13 @@ export class IssueFacade { ); } + /** Follow the ticket (or stop): be told when it moves or gets a comment. */ + setWatching(watching: boolean): void { + this.withIssue((issueId) => + this.dispatch.watchRequested({ issueId, watching }), + ); + } + /** Attaches files to the ticket (or under one of its comments). */ attach(files: File[], commentId: string | null = null): void { if (!files.length) return; diff --git a/libs/web/tracker/application/src/lib/stores/issue.store.ts b/libs/web/tracker/application/src/lib/stores/issue.store.ts index cbdf751..09fb1d4 100644 --- a/libs/web/tracker/application/src/lib/stores/issue.store.ts +++ b/libs/web/tracker/application/src/lib/stores/issue.store.ts @@ -60,6 +60,11 @@ export const IssueStore = signalStore( }), withComputed(({ issue }, auth = inject(AuthFacade)) => ({ me: computed(() => auth.user()?.id ?? null), + watching: computed( + () => + !!auth.user() && + (issue()?.watchers ?? []).some((w) => w.id === auth.user()?.id), + ), outwardLinks: computed( () => issue()?.links.filter((l) => l.direction === 'OUTWARD') ?? [], ), diff --git a/libs/web/tracker/domain/src/index.ts b/libs/web/tracker/domain/src/index.ts index 3a57f5e..eeba170 100644 --- a/libs/web/tracker/domain/src/index.ts +++ b/libs/web/tracker/domain/src/index.ts @@ -3,3 +3,4 @@ export * from './lib/models/models'; export * from './lib/models/inputs'; export * from './lib/models/workflow'; export * from './lib/ports/gateways'; +export * from './lib/models/mentions'; diff --git a/libs/web/tracker/domain/src/lib/models/mentions.spec.ts b/libs/web/tracker/domain/src/lib/models/mentions.spec.ts new file mode 100644 index 0000000..d2b6a62 --- /dev/null +++ b/libs/web/tracker/domain/src/lib/models/mentions.spec.ts @@ -0,0 +1,30 @@ +import { + insertMention, + matches, + mentionQuery, + mentionSegments, +} from './mentions'; + +describe('mentions', () => { + it('splits a text into words and mentions', () => { + expect(mentionSegments('Hola @[Ana](u1), ¿lo miras?')).toEqual([ + { text: 'Hola ' }, + { mention: 'Ana', userId: 'u1' }, + { text: ', ¿lo miras?' }, + ]); + }); + + it('notices an @ being typed and completes it', () => { + expect(mentionQuery('Pregunta a @an')).toBe('an'); + expect(mentionQuery('Pregunta a @')).toBe(''); + expect(mentionQuery('correo@dominio')).toBeNull(); + expect( + insertMention('Pregunta a @an', { id: 'u1', name: 'Ana García' }), + ).toBe('Pregunta a @[Ana García](u1) '); + }); + + it('finds people ignoring accents and case', () => { + expect(matches('José Pérez', 'jose pe')).toBe(true); + expect(matches('Ana', 'bob')).toBe(false); + }); +}); diff --git a/libs/web/tracker/domain/src/lib/models/mentions.ts b/libs/web/tracker/domain/src/lib/models/mentions.ts new file mode 100644 index 0000000..2dc0b54 --- /dev/null +++ b/libs/web/tracker/domain/src/lib/models/mentions.ts @@ -0,0 +1,52 @@ +/** + * Mentions are stored as `@[Ana García]()` — the API reads the id to + * tell that person; people see `@Ana García`. + */ +const MENTION = /@\[([^\]\n]{1,80})\]\(([\w-]{1,64})\)/g; + +export type TextSegment = + | { text: string } + | { mention: string; userId: string }; + +/** The text in pieces, to show mentions highlighted. */ +export function mentionSegments(text: string): TextSegment[] { + const segments: TextSegment[] = []; + let last = 0; + for (const match of text.matchAll(MENTION)) { + const at = match.index ?? 0; + if (at > last) segments.push({ text: text.slice(last, at) }); + segments.push({ mention: match[1], userId: match[2] }); + last = at + match[0].length; + } + if (last < text.length) segments.push({ text: text.slice(last) }); + return segments; +} + +const TYPING = /(^|\s)@([^\s@[\]()]{0,30})$/u; + +/** What is being typed after an `@` at the end of the text, or null. */ +export function mentionQuery(text: string): string | null { + return TYPING.exec(text)?.[2] ?? null; +} + +/** Replaces the `@what-was-typed` at the end with the mention. */ +export function insertMention( + text: string, + user: { id: string; name: string }, +): string { + const name = user.name.replace(/[[\]()\n]/g, ' ').trim(); + return text.replace( + TYPING, + (_, before: string) => `${before}@[${name}](${user.id}) `, + ); +} + +/** Case- and accent-insensitive "contains". */ +export function matches(name: string, query: string): boolean { + const fold = (s: string) => + s + .normalize('NFD') + .replace(/\p{Diacritic}/gu, '') + .toLowerCase(); + return fold(name).includes(fold(query)); +} diff --git a/libs/web/tracker/domain/src/lib/models/models.ts b/libs/web/tracker/domain/src/lib/models/models.ts index 1fadbb0..36675fd 100644 --- a/libs/web/tracker/domain/src/lib/models/models.ts +++ b/libs/web/tracker/domain/src/lib/models/models.ts @@ -86,6 +86,8 @@ export interface Issue extends IssueSummary { dueDate: string | null; links: IssueLink[]; attachments: Attachment[]; + /** People following the ticket. */ + watchers: UserRef[]; allowedTransitions: string[]; createdAt: string; } diff --git a/libs/web/tracker/domain/src/lib/ports/gateways.ts b/libs/web/tracker/domain/src/lib/ports/gateways.ts index 184ffb9..561ea7d 100644 --- a/libs/web/tracker/domain/src/lib/ports/gateways.ts +++ b/libs/web/tracker/domain/src/lib/ports/gateways.ts @@ -87,6 +87,8 @@ export abstract class IssuesGateway { issueId: string, releaseId: string | null, ): Observable; + /** The signed-in person follows (or stops following) the ticket. */ + abstract watch(issueId: string, watching: boolean): Observable; } export abstract class CommentsGateway { diff --git a/libs/web/tracker/infrastructure/src/lib/adapters/graphql-issues.gateway.ts b/libs/web/tracker/infrastructure/src/lib/adapters/graphql-issues.gateway.ts index 6e99126..6d946e2 100644 --- a/libs/web/tracker/infrastructure/src/lib/adapters/graphql-issues.gateway.ts +++ b/libs/web/tracker/infrastructure/src/lib/adapters/graphql-issues.gateway.ts @@ -25,6 +25,8 @@ import { SearchIssuesDocument, SetIssueReleaseDocument, UnlinkIssuesDocument, + UnwatchIssueDocument, + WatchIssueDocument, UpdateIssueDocument, } from '../graphql/generated'; import { GraphqlOperations } from './graphql-operations'; @@ -119,6 +121,16 @@ export class GraphqlIssuesGateway extends IssuesGateway { .pipe(map((d) => toCommandResult(d.unlinkIssues))); } + watch(issueId: string, watching: boolean): Observable { + return watching + ? this.gql + .mutate(WatchIssueDocument, { issueId }) + .pipe(map((d) => toCommandResult(d.watchIssue))) + : this.gql + .mutate(UnwatchIssueDocument, { issueId }) + .pipe(map((d) => toCommandResult(d.unwatchIssue))); + } + setRelease( issueId: string, releaseId: string | null, diff --git a/libs/web/tracker/infrastructure/src/lib/graphql/fragments.graphql b/libs/web/tracker/infrastructure/src/lib/graphql/fragments.graphql index b3c5363..3e11b54 100644 --- a/libs/web/tracker/infrastructure/src/lib/graphql/fragments.graphql +++ b/libs/web/tracker/infrastructure/src/lib/graphql/fragments.graphql @@ -88,6 +88,9 @@ fragment IssueFields on Issue { issueId issueKey } + watchers { + ...UserRefFields + } attachments { id fileName diff --git a/libs/web/tracker/infrastructure/src/lib/graphql/generated.ts b/libs/web/tracker/infrastructure/src/lib/graphql/generated.ts index 85327a6..1d9ec3f 100644 --- a/libs/web/tracker/infrastructure/src/lib/graphql/generated.ts +++ b/libs/web/tracker/infrastructure/src/lib/graphql/generated.ts @@ -248,6 +248,7 @@ export type IssueFieldsFragment = { issueId: string; issueKey: string; }>; + watchers: Array<{ id: string; name: string }>; attachments: Array<{ id: string; fileName: string; @@ -362,6 +363,7 @@ export type IssueQuery = { issueId: string; issueKey: string; }>; + watchers: Array<{ id: string; name: string }>; attachments: Array<{ id: string; fileName: string; @@ -451,6 +453,22 @@ export type UnlinkIssuesMutation = { unlinkIssues: { id: string; key: string | null; version: number }; }; +export type WatchIssueMutationVariables = Exact<{ + issueId: string; +}>; + +export type WatchIssueMutation = { + watchIssue: { id: string; key: string | null; version: number }; +}; + +export type UnwatchIssueMutationVariables = Exact<{ + issueId: string; +}>; + +export type UnwatchIssueMutation = { + unwatchIssue: { id: string; key: string | null; version: number }; +}; + export type SetIssueReleaseMutationVariables = Exact<{ issueId: string; releaseId?: string | null | undefined; @@ -1400,6 +1418,19 @@ export const IssueFieldsFragmentDoc = { ], }, }, + { + kind: 'Field', + name: { kind: 'Name', value: 'watchers' }, + selectionSet: { + kind: 'SelectionSet', + selections: [ + { + kind: 'FragmentSpread', + name: { kind: 'Name', value: 'UserRefFields' }, + }, + ], + }, + }, { kind: 'Field', name: { kind: 'Name', value: 'attachments' }, @@ -2066,6 +2097,19 @@ export const IssueDocument = { ], }, }, + { + kind: 'Field', + name: { kind: 'Name', value: 'watchers' }, + selectionSet: { + kind: 'SelectionSet', + selections: [ + { + kind: 'FragmentSpread', + name: { kind: 'Name', value: 'UserRefFields' }, + }, + ], + }, + }, { kind: 'Field', name: { kind: 'Name', value: 'attachments' }, @@ -2671,6 +2715,143 @@ export const UnlinkIssuesDocument = { UnlinkIssuesMutation, UnlinkIssuesMutationVariables >; +export const WatchIssueDocument = { + kind: 'Document', + definitions: [ + { + kind: 'OperationDefinition', + operation: 'mutation', + name: { kind: 'Name', value: 'WatchIssue' }, + variableDefinitions: [ + { + kind: 'VariableDefinition', + variable: { + kind: 'Variable', + name: { kind: 'Name', value: 'issueId' }, + }, + type: { + kind: 'NonNullType', + type: { kind: 'NamedType', name: { kind: 'Name', value: 'ID' } }, + }, + }, + ], + selectionSet: { + kind: 'SelectionSet', + selections: [ + { + kind: 'Field', + name: { kind: 'Name', value: 'watchIssue' }, + arguments: [ + { + kind: 'Argument', + name: { kind: 'Name', value: 'issueId' }, + value: { + kind: 'Variable', + name: { kind: 'Name', value: 'issueId' }, + }, + }, + ], + selectionSet: { + kind: 'SelectionSet', + selections: [ + { + kind: 'FragmentSpread', + name: { kind: 'Name', value: 'CommandResultFields' }, + }, + ], + }, + }, + ], + }, + }, + { + kind: 'FragmentDefinition', + name: { kind: 'Name', value: 'CommandResultFields' }, + typeCondition: { + kind: 'NamedType', + name: { kind: 'Name', value: 'CommandResult' }, + }, + selectionSet: { + kind: 'SelectionSet', + selections: [ + { kind: 'Field', name: { kind: 'Name', value: 'id' } }, + { kind: 'Field', name: { kind: 'Name', value: 'key' } }, + { kind: 'Field', name: { kind: 'Name', value: 'version' } }, + ], + }, + }, + ], +} as unknown as DocumentNode; +export const UnwatchIssueDocument = { + kind: 'Document', + definitions: [ + { + kind: 'OperationDefinition', + operation: 'mutation', + name: { kind: 'Name', value: 'UnwatchIssue' }, + variableDefinitions: [ + { + kind: 'VariableDefinition', + variable: { + kind: 'Variable', + name: { kind: 'Name', value: 'issueId' }, + }, + type: { + kind: 'NonNullType', + type: { kind: 'NamedType', name: { kind: 'Name', value: 'ID' } }, + }, + }, + ], + selectionSet: { + kind: 'SelectionSet', + selections: [ + { + kind: 'Field', + name: { kind: 'Name', value: 'unwatchIssue' }, + arguments: [ + { + kind: 'Argument', + name: { kind: 'Name', value: 'issueId' }, + value: { + kind: 'Variable', + name: { kind: 'Name', value: 'issueId' }, + }, + }, + ], + selectionSet: { + kind: 'SelectionSet', + selections: [ + { + kind: 'FragmentSpread', + name: { kind: 'Name', value: 'CommandResultFields' }, + }, + ], + }, + }, + ], + }, + }, + { + kind: 'FragmentDefinition', + name: { kind: 'Name', value: 'CommandResultFields' }, + typeCondition: { + kind: 'NamedType', + name: { kind: 'Name', value: 'CommandResult' }, + }, + selectionSet: { + kind: 'SelectionSet', + selections: [ + { kind: 'Field', name: { kind: 'Name', value: 'id' } }, + { kind: 'Field', name: { kind: 'Name', value: 'key' } }, + { kind: 'Field', name: { kind: 'Name', value: 'version' } }, + ], + }, + }, + ], +} as unknown as DocumentNode< + UnwatchIssueMutation, + UnwatchIssueMutationVariables +>; export const SetIssueReleaseDocument = { kind: 'Document', definitions: [ diff --git a/libs/web/tracker/infrastructure/src/lib/graphql/issues.graphql b/libs/web/tracker/infrastructure/src/lib/graphql/issues.graphql index a3fbf1d..62559a7 100644 --- a/libs/web/tracker/infrastructure/src/lib/graphql/issues.graphql +++ b/libs/web/tracker/infrastructure/src/lib/graphql/issues.graphql @@ -66,6 +66,18 @@ mutation UnlinkIssues($input: LinkIssuesInput!) { } } +mutation WatchIssue($issueId: ID!) { + watchIssue(issueId: $issueId) { + ...CommandResultFields + } +} + +mutation UnwatchIssue($issueId: ID!) { + unwatchIssue(issueId: $issueId) { + ...CommandResultFields + } +} + mutation SetIssueRelease($issueId: ID!, $releaseId: ID) { setIssueRelease(issueId: $issueId, releaseId: $releaseId) { ...CommandResultFields diff --git a/libs/web/tracker/presenters/src/lib/pages/issue.page.scss b/libs/web/tracker/presenters/src/lib/pages/issue.page.scss index 3617bea..df0777e 100644 --- a/libs/web/tracker/presenters/src/lib/pages/issue.page.scss +++ b/libs/web/tracker/presenters/src/lib/pages/issue.page.scss @@ -345,3 +345,39 @@ a.ts-key { gap: 0.5rem; margin: 0.5rem 0; } + +.ts-mention { + padding: 0 0.2em; + border-radius: 0.25em; + background: color-mix(in oklab, var(--ts-brand) 14%, transparent); + color: inherit; + font-weight: 600; +} + +.ts-mention.is-me { + background: color-mix(in oklab, var(--ts-brand) 30%, transparent); +} + +.ts-mentions { + display: flex; + flex-wrap: wrap; + gap: 0.375rem; +} + +.ts-mentions__option { + display: inline-flex; + align-items: center; + gap: 0.5rem; + padding: 0.25rem 0.625rem 0.25rem 0.25rem; + border: 1px solid rgba(127, 127, 127, 0.3); + border-radius: 999px; + background: transparent; + color: inherit; + font: inherit; + cursor: pointer; +} + +.ts-mentions__option:hover, +.ts-mentions__option:focus-visible { + border-color: currentColor; +} diff --git a/libs/web/tracker/presenters/src/lib/pages/issue.page.ts b/libs/web/tracker/presenters/src/lib/pages/issue.page.ts index 9a094ec..65d8d79 100644 --- a/libs/web/tracker/presenters/src/lib/pages/issue.page.ts +++ b/libs/web/tracker/presenters/src/lib/pages/issue.page.ts @@ -35,9 +35,13 @@ import { import { Attachment, Comment, + insertMention, IssueLink, IssueLinkType, IssuePriority, + matches, + mentionQuery, + mentionSegments, ReleaseStatus, } from '@task-stream/web/tracker/domain'; import { @@ -95,6 +99,20 @@ const MAX_FILE_BYTES = 25 * 1024 * 1024;

{{ issue.title }}

+ @if (project.myRole()) { + + } @if (project.canContribute()) { }
-

{{ comment.body }}

+

+ @for (part of segments(comment.body); track $index) { + @if ('mention' in part) { + @{{ part.mention }} + } @else { + {{ part.text }} + } + } +

@if (filesOf(comment).length) {
@for (file of filesOf(comment); track file.id) { @@ -308,9 +338,29 @@ const MAX_FILE_BYTES = 25 * 1024 * 1024; novalidate > + @if (mentionOptions().length) { +
+ @for (member of mentionOptions(); track member.id) { + + } +
+ } ([]); + protected readonly segments = mentionSegments; + /** Members matching what is typed after an `@` at the end of the comment. */ + protected readonly mentionOptions = computed(() => { + const query = mentionQuery(this.commentBody()); + if (query === null) return []; + return this.project + .members() + .map((m) => m.user) + .filter( + (user) => user.id !== this.facade.me() && matches(user.name, query), + ) + .slice(0, 6); + }); protected readonly pendingFiles = signal([]); protected readonly maxFileBytes = MAX_FILE_BYTES; @@ -679,6 +742,10 @@ export class IssuePage { this.commentFiles.set([]); } + protected mention(user: { id: string; name: string }): void { + this.commentBody.update((text) => insertMention(text, user)); + } + protected attach(): void { this.facade.attach(this.pendingFiles()); this.pendingFiles.set([]); diff --git a/libs/web/tracker/presenters/src/lib/ui/notifications-bell.ts b/libs/web/tracker/presenters/src/lib/ui/notifications-bell.ts index 5ebfe7a..f2008e4 100644 --- a/libs/web/tracker/presenters/src/lib/ui/notifications-bell.ts +++ b/libs/web/tracker/presenters/src/lib/ui/notifications-bell.ts @@ -190,7 +190,7 @@ const ICON: Record = { height: 0.5rem; margin-top: 0.5rem; border-radius: 50%; - background: #a347e1; + background: var(--ts-brand, #a347e1); } .ts-inbox__empty { opacity: 0.7;