From 199b60e4ce3f16d47e7606b6e7b3b4fda5d423d9 Mon Sep 17 00:00:00 2001 From: Josep Piguillem Date: Mon, 19 Oct 2026 22:35:46 +0200 Subject: [PATCH] feat: conversations on a ticket are settled, and you see who is typing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A top-level comment and its replies can be resolved by whoever can change the ticket (CommentThreadResolvedEvent), with an optional conclusion added as a reply marked as a decision, so it shows above the ticket. A settled conversation folds to one line (topic, who settled it and the conclusion) and can be unfolded or reopened; the header counts the open ones, and finishing the ticket with one open asks first. «Ana está escribiendo…» shows in the conversation being answered, at once: a `typing` mutation publishes to a Redis channel per ticket (`typing` subscription), never stored. In replies, Enter sends and Shift+Enter breaks the line, except inside lists and code. API: resolveThread / PUT-DELETE /api/comments/{id}/resolution, Comment .resolved/resolvedBy/resolvedAt, typing mutation and subscription. MCP: resolve_thread. Co-Authored-By: Claude Opus 5.5 --- apps/api/openapi.json | 239 ++++++++++++ apps/api/schema.gql | 37 ++ apps/mcp/src/app/api/taskstream.schema.ts | 185 +++++++++ apps/mcp/src/app/tools/issues.resolver.ts | 32 ++ docs/01-vision-general.md | 86 ++-- docs/03-api.md | 30 +- docs/04-cliente-web.md | 2 +- docs/09-mcp.md | 1 + .../application/src/lib/changes.service.ts | 10 + .../src/lib/commands/command-handlers.ts | 2 + .../src/lib/commands/commands.spec.ts | 46 +++ .../resolve-thread.command-handler.ts | 44 +++ .../lib/commands/resolve-thread.command.ts | 17 + .../application/src/lib/comments.service.ts | 16 + .../comment-added.event-handler.ts | 3 + .../comment-thread-resolved.event-handler.ts | 28 ++ .../src/lib/event-handlers/event-handlers.ts | 2 + .../application/src/lib/ports/change-feed.ts | 11 + .../src/lib/presence.service.spec.ts | 43 +- .../application/src/lib/presence.service.ts | 20 + .../tracker/domain/src/lib/comment/comment.ts | 29 ++ .../events/comment-thread-resolved.event.ts | 17 + .../tracker/domain/src/lib/events/index.ts | 1 + .../src/lib/read-models/comment.read-model.ts | 5 + .../src/lib/mongo/find.repositories.ts | 3 + .../src/lib/realtime/redis-change-feed.ts | 10 + .../src/lib/graphql/comment.resolver.ts | 18 + .../src/lib/graphql/issue.resolver.ts | 22 ++ .../src/lib/graphql/subscription.resolver.ts | 13 + .../presenters/src/lib/graphql/types.ts | 25 ++ .../src/lib/http/comments.controller.ts | 35 +- .../src/lib/http/dto/requests.dto.ts | 12 + .../src/lib/http/dto/responses.dto.ts | 13 + .../src/lib/events/issue.events.ts | 5 + .../src/lib/events/presence.events.ts | 8 +- .../src/lib/facades/issue.facade.ts | 16 + .../src/lib/facades/presence.facade.ts | 7 + .../application/src/lib/stores/issue.store.ts | 20 + .../src/lib/stores/presence.store.ts | 80 +++- .../tracker/domain/src/lib/models/models.ts | 12 + .../tracker/domain/src/lib/ports/gateways.ts | 14 + .../graphql-collaboration.gateways.ts | 11 + .../lib/adapters/graphql-presence.gateway.ts | 20 +- .../src/lib/graphql/fragments.graphql | 4 + .../src/lib/graphql/generated.ts | 368 ++++++++++++++++++ .../src/lib/graphql/issues.graphql | 14 + .../src/lib/graphql/presence.graphql | 14 + .../presenters/src/lib/pages/issue.page.scss | 88 ++++- .../presenters/src/lib/pages/issue.page.ts | 331 +++++++++++++--- .../presenters/src/lib/ui/rich-editor.ts | 6 +- .../presenters/src/lib/ui/rich-text.ts | 16 + 51 files changed, 1965 insertions(+), 126 deletions(-) create mode 100644 libs/api/tracker/application/src/lib/commands/resolve-thread.command-handler.ts create mode 100644 libs/api/tracker/application/src/lib/commands/resolve-thread.command.ts create mode 100644 libs/api/tracker/application/src/lib/event-handlers/comment-thread-resolved.event-handler.ts create mode 100644 libs/api/tracker/domain/src/lib/events/comment-thread-resolved.event.ts diff --git a/apps/api/openapi.json b/apps/api/openapi.json index c713180..558df33 100644 --- a/apps/api/openapi.json +++ b/apps/api/openapi.json @@ -7113,6 +7113,213 @@ ] } }, + "/api/comments/{commentId}/resolution": { + "put": { + "description": "Whoever can change the ticket. A conclusion is added as a reply marked as a decision. The web folds a settled conversation.", + "operationId": "CommentsController_resolveThread", + "parameters": [ + { + "name": "commentId", + "required": true, + "in": "path", + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ResolveThreadDto" + } + } + } + }, + "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": "Settle a conversation (a top-level comment and its replies)", + "tags": [ + "Comments" + ] + }, + "delete": { + "operationId": "CommentsController_reopenThread", + "parameters": [ + { + "name": "commentId", + "required": true, + "in": "path", + "schema": { + "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": "Reopen a conversation", + "tags": [ + "Comments" + ] + } + }, "/api/me/releases": { "get": { "operationId": "ReleasesController_upcoming", @@ -16162,6 +16369,25 @@ "nullable": true, "format": "date-time" }, + "resolved": { + "type": "boolean", + "description": "A conversation (top-level comment and its replies) that was settled" + }, + "resolvedBy": { + "nullable": true, + "description": "Who settled it", + "type": "object", + "allOf": [ + { + "$ref": "#/components/schemas/UserRefDto" + } + ] + }, + "resolvedAt": { + "type": "string", + "nullable": true, + "format": "date-time" + }, "createdAt": { "type": "string", "format": "date-time" @@ -16186,6 +16412,9 @@ "decision", "decidedBy", "decidedAt", + "resolved", + "resolvedBy", + "resolvedAt", "createdAt", "editedAt", "version" @@ -16328,6 +16557,16 @@ "on" ] }, + "ResolveThreadDto": { + "type": "object", + "properties": { + "conclusion": { + "type": "string", + "description": "What was settled: added as a reply marked as a decision before it closes", + "maxLength": 20000 + } + } + }, "ReleaseStatus": { "type": "string", "enum": [ diff --git a/apps/api/schema.gql b/apps/api/schema.gql index 0c74d4c..ce4a982 100644 --- a/apps/api/schema.gql +++ b/apps/api/schema.gql @@ -310,6 +310,13 @@ type Comment { """The top-level comment it answers; null for a top-level one""" replyTo: ID + + """A conversation (top-level comment and its replies) that was settled""" + resolved: Boolean! + resolvedAt: String + + """Who settled it""" + resolvedBy: UserRef version: Int! } @@ -1049,6 +1056,11 @@ type Mutation { removeGitRepository(projectId: ID!, repositoryId: ID!): Boolean! removeProjectMember(projectId: ID!, userId: ID!): CommandResult! + """ + Settle a conversation (a top-level comment and its replies) or reopen it (resolved: false). A conclusion is added as a reply marked as a decision. Whoever can change the ticket + """ + resolveThread(commentId: ID!, conclusion: String, resolved: Boolean! = true): CommandResult! + """Back from the trash""" restoreIssue(issueId: ID!): CommandResult! @@ -1105,6 +1117,16 @@ type Mutation { """ trashIssue(issueId: ID!): CommandResult! + """ + I am writing on this ticket (send every few seconds while typing): those with it open see it at once via `typing` + """ + typing( + issueId: ID! + + """The conversation being answered; null: a new comment""" + threadId: ID + ): Boolean! + """Bring an archived project back (owner only)""" unarchiveProject(projectId: ID!): CommandResult! unlinkIssues(input: LinkIssuesInput!): CommandResult! @@ -1988,6 +2010,11 @@ type Subscription { projectChanged(projectId: ID!): ProjectChange! releaseChanged(projectId: ID!): ReleaseChange! sprintChanged(projectId: ID!): SprintChange! + + """ + Who is writing on a ticket, as they type (one's own come too: leave them out) + """ + typing(issueId: ID!): Typing! } type Suggestion { @@ -2038,6 +2065,16 @@ type Throughput { week: String! } +"""Someone writing on a ticket right now (not stored)""" +type Typing { + at: String! + issueId: ID! + + """The conversation being answered; null: a new comment""" + threadId: ID + user: UserRef! +} + input UpdateChecklistItemInput { done: Boolean issueId: ID! diff --git a/apps/mcp/src/app/api/taskstream.schema.ts b/apps/mcp/src/app/api/taskstream.schema.ts index 4a4cf5c..139ca6f 100644 --- a/apps/mcp/src/app/api/taskstream.schema.ts +++ b/apps/mcp/src/app/api/taskstream.schema.ts @@ -1050,6 +1050,27 @@ export interface paths { patch?: never; trace?: never; }; + "/api/comments/{commentId}/resolution": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + /** + * Settle a conversation (a top-level comment and its replies) + * @description Whoever can change the ticket. A conclusion is added as a reply marked as a decision. The web folds a settled conversation. + */ + put: operations["CommentsController_resolveThread"]; + post?: never; + /** Reopen a conversation */ + delete: operations["CommentsController_reopenThread"]; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/me/releases": { parameters: { query?: never; @@ -3080,6 +3101,12 @@ export interface components { decidedBy: components["schemas"]["UserRefDto"] | null; /** Format: date-time */ decidedAt: string | null; + /** @description A conversation (top-level comment and its replies) that was settled */ + resolved: boolean; + /** @description Who settled it */ + resolvedBy: components["schemas"]["UserRefDto"] | null; + /** Format: date-time */ + resolvedAt: string | null; /** Format: date-time */ createdAt: string; /** Format: date-time */ @@ -3144,6 +3171,10 @@ export interface components { /** @description true adds the reaction, false takes it back */ on: boolean; }; + ResolveThreadDto: { + /** @description What was settled: added as a reply marked as a decision before it closes */ + conclusion?: string; + }; /** @enum {string} */ ReleaseStatus: "PLANNED" | "RELEASED" | "ARCHIVED"; ReleaseDto: { @@ -8596,6 +8627,160 @@ export interface operations { }; }; }; + CommentsController_resolveThread: { + parameters: { + query?: never; + header?: never; + path: { + commentId: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["ResolveThreadDto"]; + }; + }; + 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"]; + }; + }; + }; + }; + CommentsController_reopenThread: { + parameters: { + query?: never; + header?: never; + path: { + commentId: 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"]; + }; + }; + }; + }; ReleasesController_upcoming: { parameters: { query?: { diff --git a/apps/mcp/src/app/tools/issues.resolver.ts b/apps/mcp/src/app/tools/issues.resolver.ts index d6f2d07..a4f8dae 100644 --- a/apps/mcp/src/app/tools/issues.resolver.ts +++ b/apps/mcp/src/app/tools/issues.resolver.ts @@ -290,6 +290,7 @@ export class IssuesResolver { by: r.users.map((u) => u.name), })), decision: c.decision || undefined, + resolved: c.resolved || undefined, })) : undefined; const decisions = ( @@ -772,6 +773,37 @@ export class IssuesResolver { ); } + @Tool({ + name: 'resolve_thread', + description: + 'Settle a conversation (a top-level comment and its replies), optionally with a conclusion that is added as a reply marked as a decision; or reopen it (resolved: false). Whoever can change the ticket. Comment ids come with get_issue.', + paramsSchema: z.object({ + commentId: z.string().min(1), + resolved: z.boolean().default(true), + conclusion: z.string().max(20000).optional(), + }), + annotations: { ...WRITE, idempotentHint: true }, + }) + resolveThread( + params: { commentId: string; resolved: boolean; conclusion?: string }, + ctx: McpContext, + ) { + const client = this.api.as(ctx); + const path = { commentId: params.commentId }; + return toolResult(() => + this.api.data( + params.resolved + ? client.PUT('/api/comments/{commentId}/resolution', { + params: { path }, + body: { conclusion: params.conclusion }, + }) + : client.DELETE('/api/comments/{commentId}/resolution', { + params: { path }, + }), + ), + ); + } + @Tool({ name: 'search_decisions', description: diff --git a/docs/01-vision-general.md b/docs/01-vision-general.md index 47c5780..b37068f 100644 --- a/docs/01-vision-general.md +++ b/docs/01-vision-general.md @@ -2,49 +2,49 @@ ## Qué resuelve -| Necesidad | Cómo se resuelve | -| --------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| 🐛 Bugs | Tickets de tipo `BUG` con prioridad, área y etiquetas | -| 🌍 Públicos | Un proyecto público se puede leer sin iniciar sesión (web, GraphQL y REST): tablero, tickets, comentarios, releases, actividad y estadísticas; «Explorar» los lista | -| 🗑️ Borrar | El propietario borra un proyecto confirmando su clave; tickets, comentarios, releases y enlaces desde otros proyectos se borran en cascada (sagas) y la clave queda libre | -| 🧩 Configuración | Cada proyecto tiene sus estados (con categoría, color y motivo obligatorio), transiciones, tipos de ticket y áreas; lo usado se archiva, no se borra | -| 🎨 Cambios de UI | Tipo `UI_CHANGE` con enlace al frame de Figma (validado contra `figma.com`) | -| ⚙️ Features | Tipo `FEATURE` siguiendo el workflow completo | -| 🧪 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) | -| 📝 Notas de versión | Borrador en Markdown con los tickets terminados por tipo, editable y copiable, también tras publicar | -| 👥 Asignaciones | Solo a miembros del proyecto; panel de carga por persona | -| 💬 Comentarios | Decisiones y explicaciones dentro del ticket, con formato (editor visual, guardado en Markdown), capturas pegadas, respuestas, reacciones (👍 🎉…), decisiones (un comentario marcado sale encima del ticket y se encuentra al buscar) y `@menciones`; el autor edita, el admin modera | -| 🔎 Vistas | El tablero se filtra por texto, persona (o sin asignar), tipo, prioridad, área y release; los filtros se guardan como vistas propias o compartidas con el proyecto | -| 📋 Lista | Todos los tickets en filas: ordenar por cualquier columna, filtrar, cambiar estado, prioridad o persona sin abrirlos, editar en bloque y duplicar | -| ↩️ Deshacer | Mover, reasignar o mandar a la papelera ofrece «Deshacer» unos segundos (también Ctrl/⌘ Z), con la orden contraria | -| 🚦 WIP y carriles | Límite de trabajo en curso por estado (la columna se marca al pasarse) y tablero agrupable por persona, épica, prioridad o tipo | -| 🎲 Estimación | Puntos por ticket (escala 0-1-2-3-5-8-13-21) en la ficha, las tarjetas, las columnas y el backlog; velocidad por sprint y aviso si un sprint planificado supera la media | -| 🏃 Sprints | Iteraciones con fechas y objetivo, una en curso por proyecto, con burndown (tickets o puntos) y traspaso de lo pendiente al cerrar | -| 📅 Calendario | Los sprints mes a mes como franjas, con las releases y los vencimientos de cada día; se planifica un sprint pulsando su primer día y todo se descarga en .ics | -| 📋 Backlog | Lo pendiente de empezar, en el orden en que se va a coger; se reordena arrastrando y el tablero sigue ese orden | -| 🗑️ Papelera | Borrar un ticket lo manda a la papelera (quien lo abrió o un admin); se recupera durante 30 días y luego se borra del todo | -| ☑️ En bloque | Selecciona varios tickets del backlog y cámbiales a la vez estado, persona, sprint, prioridad o etiqueta | -| ⌨️ Atajos y búsqueda | Ctrl/⌘ K o / busca tickets, decisiones y proyectos de todos tus proyectos (antes de escribir, tus tickets fijados); `g b`, `g k`, `g s`, `g r` navegan, `c` crea, `e` edita, `?` los lista | -| 🗺️ Hoja de ruta | Las épicas en el tiempo (fecha de inicio y límite), con los sprints y las releases como referencias y flechas entre las que dependen unas de otras | -| 🗳️ Propuestas y votos | Un proyecto público puede abrir un formulario para que cualquiera, sin cuenta, reporte un error o proponga una idea; quien tenga sesión vota lo que quiere | -| 🌐 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 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 | -| 🔗 Relaciones | `BLOCKS`, `DEPENDS_ON`, `TESTS`, `DUPLICATES`, `RELATES_TO`, también entre proyectos (FE ↔ BE ↔ QA); lo que espera a otro ticket abierto sale marcado como «Bloqueado» | -| 🌿 Git | Webhook para GitHub, Gitea o GitLab: commits, ramas y pull requests que nombran un ticket (WEB-12) aparecen en su ficha | -| 🚨 Alertas → bugs | Sentry, Uptime Kuma, Grafana o cualquier herramienta abren bugs por una URL secreta; el mismo problema repetido va al bug abierto, y «resuelta» se dice en él | -| 🧾 Plantillas | Cada tipo de ticket puede traer una descripción inicial y una checklist que se aplican al crearlo | -| 🤖 Automatizaciones | Reglas «cuando… si… entonces…»: al crear, mover, asignar, comentar o fusionar una PR, mover, asignar, etiquetar, priorizar, comentar o meter en el sprint | -| 📡 Webhooks | Un POST firmado (HMAC-SHA256) a otro sistema cuando pasa algo: tickets, comentarios, releases, sprints; con prueba y último envío | -| 📦 Importar/exportar | Todos los tickets a CSV; importar un CSV propio o una exportación de Jira, con vista previa de lo que se va a crear | -| 🏠 Inicio | «Mi trabajo»: mis tickets en todos los proyectos, por categoría (QA fallidos primero), lo que espera QA, próximas releases e «Ir a…» por clave | -| 📊 Seguimiento | Panel con contadores, flujo por estado, el equipo con sus tickets abiertos (enlaza a su tablero), feed de actividad e historial por ticket | -| 📈 Estadísticas | Creados vs. resueltos, flujo acumulado, burnup de releases, tiempos de ciclo y entrega, tasa de fallo en QA y resueltos por persona | -| ⚡ Tiempo real | Tablero, comentarios, actividad, releases y membresías se actualizan solos (GraphQL subscriptions) | +| Necesidad | Cómo se resuelve | +| --------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| 🐛 Bugs | Tickets de tipo `BUG` con prioridad, área y etiquetas | +| 🌍 Públicos | Un proyecto público se puede leer sin iniciar sesión (web, GraphQL y REST): tablero, tickets, comentarios, releases, actividad y estadísticas; «Explorar» los lista | +| 🗑️ Borrar | El propietario borra un proyecto confirmando su clave; tickets, comentarios, releases y enlaces desde otros proyectos se borran en cascada (sagas) y la clave queda libre | +| 🧩 Configuración | Cada proyecto tiene sus estados (con categoría, color y motivo obligatorio), transiciones, tipos de ticket y áreas; lo usado se archiva, no se borra | +| 🎨 Cambios de UI | Tipo `UI_CHANGE` con enlace al frame de Figma (validado contra `figma.com`) | +| ⚙️ Features | Tipo `FEATURE` siguiendo el workflow completo | +| 🧪 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) | +| 📝 Notas de versión | Borrador en Markdown con los tickets terminados por tipo, editable y copiable, también tras publicar | +| 👥 Asignaciones | Solo a miembros del proyecto; panel de carga por persona | +| 💬 Comentarios | Decisiones y explicaciones dentro del ticket, con formato (editor visual, guardado en Markdown), capturas pegadas, respuestas, reacciones (👍 🎉…), decisiones (un comentario marcado sale encima del ticket y se encuentra al buscar), conversaciones que se resuelven con su conclusión, «está escribiendo…» en vivo y `@menciones`; el autor edita, el admin modera | +| 🔎 Vistas | El tablero se filtra por texto, persona (o sin asignar), tipo, prioridad, área y release; los filtros se guardan como vistas propias o compartidas con el proyecto | +| 📋 Lista | Todos los tickets en filas: ordenar por cualquier columna, filtrar, cambiar estado, prioridad o persona sin abrirlos, editar en bloque y duplicar | +| ↩️ Deshacer | Mover, reasignar o mandar a la papelera ofrece «Deshacer» unos segundos (también Ctrl/⌘ Z), con la orden contraria | +| 🚦 WIP y carriles | Límite de trabajo en curso por estado (la columna se marca al pasarse) y tablero agrupable por persona, épica, prioridad o tipo | +| 🎲 Estimación | Puntos por ticket (escala 0-1-2-3-5-8-13-21) en la ficha, las tarjetas, las columnas y el backlog; velocidad por sprint y aviso si un sprint planificado supera la media | +| 🏃 Sprints | Iteraciones con fechas y objetivo, una en curso por proyecto, con burndown (tickets o puntos) y traspaso de lo pendiente al cerrar | +| 📅 Calendario | Los sprints mes a mes como franjas, con las releases y los vencimientos de cada día; se planifica un sprint pulsando su primer día y todo se descarga en .ics | +| 📋 Backlog | Lo pendiente de empezar, en el orden en que se va a coger; se reordena arrastrando y el tablero sigue ese orden | +| 🗑️ Papelera | Borrar un ticket lo manda a la papelera (quien lo abrió o un admin); se recupera durante 30 días y luego se borra del todo | +| ☑️ En bloque | Selecciona varios tickets del backlog y cámbiales a la vez estado, persona, sprint, prioridad o etiqueta | +| ⌨️ Atajos y búsqueda | Ctrl/⌘ K o / busca tickets, decisiones y proyectos de todos tus proyectos (antes de escribir, tus tickets fijados); `g b`, `g k`, `g s`, `g r` navegan, `c` crea, `e` edita, `?` los lista | +| 🗺️ Hoja de ruta | Las épicas en el tiempo (fecha de inicio y límite), con los sprints y las releases como referencias y flechas entre las que dependen unas de otras | +| 🗳️ Propuestas y votos | Un proyecto público puede abrir un formulario para que cualquiera, sin cuenta, reporte un error o proponga una idea; quien tenga sesión vota lo que quiere | +| 🌐 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 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 | +| 🔗 Relaciones | `BLOCKS`, `DEPENDS_ON`, `TESTS`, `DUPLICATES`, `RELATES_TO`, también entre proyectos (FE ↔ BE ↔ QA); lo que espera a otro ticket abierto sale marcado como «Bloqueado» | +| 🌿 Git | Webhook para GitHub, Gitea o GitLab: commits, ramas y pull requests que nombran un ticket (WEB-12) aparecen en su ficha | +| 🚨 Alertas → bugs | Sentry, Uptime Kuma, Grafana o cualquier herramienta abren bugs por una URL secreta; el mismo problema repetido va al bug abierto, y «resuelta» se dice en él | +| 🧾 Plantillas | Cada tipo de ticket puede traer una descripción inicial y una checklist que se aplican al crearlo | +| 🤖 Automatizaciones | Reglas «cuando… si… entonces…»: al crear, mover, asignar, comentar o fusionar una PR, mover, asignar, etiquetar, priorizar, comentar o meter en el sprint | +| 📡 Webhooks | Un POST firmado (HMAC-SHA256) a otro sistema cuando pasa algo: tickets, comentarios, releases, sprints; con prueba y último envío | +| 📦 Importar/exportar | Todos los tickets a CSV; importar un CSV propio o una exportación de Jira, con vista previa de lo que se va a crear | +| 🏠 Inicio | «Mi trabajo»: mis tickets en todos los proyectos, por categoría (QA fallidos primero), lo que espera QA, próximas releases e «Ir a…» por clave | +| 📊 Seguimiento | Panel con contadores, flujo por estado, el equipo con sus tickets abiertos (enlaza a su tablero), feed de actividad e historial por ticket | +| 📈 Estadísticas | Creados vs. resueltos, flujo acumulado, burnup de releases, tiempos de ciclo y entrega, tasa de fallo en QA y resueltos por persona | +| ⚡ Tiempo real | Tablero, comentarios, actividad, releases y membresías se actualizan solos (GraphQL subscriptions) | Los **usuarios no se gestionan aquí**: vienen del servidor de identidad **Pireon** ([Swagger](https://oauth.pireon.dev/api/documentation)). TaskStream gestiona proyectos, membresías y roles por diff --git a/docs/03-api.md b/docs/03-api.md index 15e8753..e082a95 100644 --- a/docs/03-api.md +++ b/docs/03-api.md @@ -239,6 +239,13 @@ stateDiagram-v2 - **Reacciones**: 👍 👎 😄 🎉 😕 ❤️ 🚀 👀 en los comentarios (`reactToComment(commentId, emoji, on)`, `POST /api/comments/{id}/reactions`); los miembros, también los lectores. Cada comentario da `reactions` con quién eligió cada una. No generan actividad ni avisos. +- **Conversaciones**: un comentario raíz y sus respuestas se resuelven (`resolveThread(commentId, resolved, conclusion)`, + `PUT/DELETE /api/comments/{id}/resolution`; evento `CommentThreadResolvedEvent`) por quien puede cambiar el ticket; + una respuesta no se resuelve sola. Si trae `conclusion`, antes de cerrarla se añade como respuesta marcada como + decisión. El comentario da `resolved`, `resolvedBy` y `resolvedAt`. **Escribiendo**: `typing(issueId, threadId)` + (mutation, cada pocos segundos mientras se escribe) avisa al momento por la subscription `typing(issueId)` a quien + tenga el ticket abierto; no se guarda (canal de Redis `tracker.typing.`), y cada cliente descarta sus propios + avisos. - **Decisiones**: quien puede cambiar el ticket marca un comentario como decisión (`markDecision(commentId, on)`, `PUT/DELETE /api/comments/{id}/decision`; evento `CommentDecisionMarkedEvent`). El comentario da `decision`, `decidedBy` y `decidedAt`; el ticket, `decisions` (más antiguas primero, `GET /api/issues/{issue}/decisions`), que la @@ -536,7 +543,7 @@ Todas devuelven `CommandResult { id, key, version }`. | ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | Proyectos | `createProject`, `updateProject`, `updateProjectSettings`, `deleteProject(confirmKey)`, `addProjectMember`, `changeProjectMemberRole`, `removeProjectMember` | | Tickets | `createIssue`, `updateIssue`, `changeIssueStatus`, `assignIssue`, `linkIssues`, `unlinkIssues`, `setIssueRelease`, `setIssueParent`, `rankIssue`, `addChecklistItem`, `updateChecklistItem`, `removeChecklistItem`, `watchIssue`, `unwatchIssue`, `pinIssue`, `unpinIssue` (devuelven `true`), `removeAttachment` (subir: por REST), `duplicateIssue`, `voteIssue` | -| Comentarios | `addComment(replyTo)`, `editComment`, `deleteComment`, `reactToComment`, `markDecision(commentId, on)`; consulta `decisions(projectId, search)` | +| Comentarios | `addComment(replyTo)`, `editComment`, `deleteComment`, `reactToComment`, `markDecision(commentId, on)`, `resolveThread(commentId, resolved, conclusion)`, `typing(issueId, threadId)`; consulta `decisions(projectId, search)` | | Webhooks | `createWebhook` (devuelve el secreto), `updateWebhook`, `deleteWebhook`, `testWebhook` | | Git | `addGitRepository` (devuelve su secreto), `rotateGitRepositorySecret`, `removeGitRepository` | | Sprints | `createSprint`, `updateSprint`, `startSprint`, `closeSprint(carryOverToSprintId)`, `deleteSprint`, `setIssueSprint` | @@ -546,15 +553,16 @@ Todas devuelven `CommandResult { id, key, version }`. ### Subscriptions -| Subscription | Se emite cuando | -| --------------------------- | ------------------------------------------------------------------------------------------------------------------- | -| `issueChanged(projectId)` | Se crea o cambia cualquier ticket del proyecto (incluye el ticket completo y `action` = nombre del evento) | -| `commentChanged(issueId)` | Se añade, edita o borra un comentario (`comment` es `null` al borrar, o el que queda sin texto si tenía respuestas) | -| `activityAdded(projectId)` | Hay una nueva entrada en el feed | -| `releaseChanged(projectId)` | Cambia una release | -| `sprintChanged(projectId)` | Se planifica, cambia, empieza, cierra o borra un sprint (`sprint` es `null` al borrar) | -| `projectChanged(projectId)` | Cambian los datos, la configuración o los miembros del proyecto | -| `myProjectsChanged` | Me añaden a un proyecto o me quitan de uno | +| Subscription | Se emite cuando | +| --------------------------- | -------------------------------------------------------------------------------------------------------------------------------- | +| `issueChanged(projectId)` | Se crea o cambia cualquier ticket del proyecto (incluye el ticket completo y `action` = nombre del evento) | +| `commentChanged(issueId)` | Se añade, edita o borra un comentario (`comment` es `null` al borrar, o el que queda sin texto si tenía respuestas) | +| `typing(issueId)` | Alguien escribe en el ticket (`threadId`: la conversación que responde; `null`, un comentario nuevo). Llegan también los propios | +| `activityAdded(projectId)` | Hay una nueva entrada en el feed | +| `releaseChanged(projectId)` | Cambia una release | +| `sprintChanged(projectId)` | Se planifica, cambia, empieza, cierra o borra un sprint (`sprint` es `null` al borrar) | +| `projectChanged(projectId)` | Cambian los datos, la configuración o los miembros del proyecto | +| `myProjectsChanged` | Me añaden a un proyecto o me quitan de uno | Solo los miembros pueden suscribirse a un proyecto; las conexiones sin token válido se cierran con el código 4403. @@ -744,7 +752,7 @@ Proyectos y tickets se pueden indicar por **id o por clave** (`WEB`, `WEB-12`). | 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`), `POST …/archive`, `POST …/unarchive`, `PUT …/settings`, `…/board`, `…/epics`, `…/stats`, `…/analytics?days=`, `…/insights`, `…/activity`, `…/members` (POST, PATCH, DELETE) | | Tickets | `GET /api/me/home` (portada), `GET /api/me/portfolio` (cartera), `GET /api/me/issues` (mi trabajo; filtros `project`, `priority`, `bugsOnly`, `overdue`), `GET/POST /api/projects/{project}/issues`, `GET/PATCH /api/issues/{issue}`, `PUT …/status`, `PUT …/assignee`, `PUT …/release`, `PUT/DELETE …/watch`, `GET /api/me/pins` y `PUT/DELETE …/pin` (fijados), `PUT/DELETE …/presence`, `PUT …/parent`, `PUT …/rank`, `POST …/checklist`, `PATCH/DELETE …/checklist/{item}`, `POST/DELETE …/links`, `GET/POST …/comments`, `GET …/history`, `POST /api/issues/{issue}/duplicate`, `PUT/DELETE …/vote` | -| Comentarios | `PATCH/DELETE /api/comments/{commentId}`, `POST …/reactions`, `PUT/DELETE …/decision`; `GET /api/issues/{issue}/decisions`, `GET /api/projects/{project}/decisions`, `GET /api/me/decisions` | +| Comentarios | `PATCH/DELETE /api/comments/{commentId}`, `POST …/reactions`, `PUT/DELETE …/decision`, `PUT/DELETE …/resolution`; `GET /api/issues/{issue}/decisions`, `GET /api/projects/{project}/decisions`, `GET /api/me/decisions` | | Sprints | `GET/POST /api/projects/{project}/sprints`, `GET/PATCH/DELETE /api/sprints/{sprint}`, `POST …/start`, `POST …/close`, `GET …/progress`, `…/burndown`, `…/issues`, `PUT /api/issues/{issue}/sprint` | | Vistas | `GET/POST /api/projects/{project}/filters`, `DELETE /api/filters/{id}` | | Avisos | `GET /api/me/notifications`, `POST /api/me/notifications/read`, `GET/PUT /api/me/notification-settings` | diff --git a/docs/04-cliente-web.md b/docs/04-cliente-web.md index fc69e02..eed6b11 100644 --- a/docs/04-cliente-web.md +++ b/docs/04-cliente-web.md @@ -308,7 +308,7 @@ visualización de la plataforma: | `/portfolio` | Cartera (con sesión): una tarjeta por proyecto activo con lo abierto y en riesgo, el sprint en curso (¿llega?), las próximas releases con su previsión y las épicas abiertas; en directo: se vuelve a leer sola cuando cambia un ticket, sprint o release de esos proyectos | | `/explore` | Proyectos públicos (sin sesión) | | `/p/:key/board` | Tablero kanban (`?release=` y `?assignee=` preseleccionan los filtros). Quien puede contribuir cambia prioridad, estimación y persona desde la propia tarjeta (selectores nativos sobre sus distintivos; el título es el enlace). Mover una tarjeta o cambiar su persona ofrece «Deshacer» unos segundos (o Ctrl/⌘ Z): la orden contraria, que el flujo puede rechazar | -| `/p/:key/issues/:issueKey` | Ticket. Encima del título, quién más lo está viendo o editando ahora (`PresenceFacade`: late cada 10 s; editar es tener abierto el formulario del ticket o de un comentario, o estar escribiendo uno). «Fijar» lo deja a mano en el inicio y en la paleta de búsqueda. Arriba, sus decisiones (comentarios marcados con la bandera por quien puede cambiar el ticket); `?comment=` lleva a un comentario y lo señala. Cambiar el estado o la persona y mandarlo a la papelera (sin confirmación) ofrecen «Deshacer» | +| `/p/:key/issues/:issueKey` | Ticket. Encima del título, quién más lo está viendo o editando ahora (`PresenceFacade`: late cada 10 s; editar es tener abierto el formulario del ticket o de un comentario, o estar escribiendo uno). «Fijar» lo deja a mano en el inicio y en la paleta de búsqueda. Arriba, sus decisiones (comentarios marcados con la bandera por quien puede cambiar el ticket); `?comment=` lleva a un comentario y lo señala. Cambiar el estado o la persona y mandarlo a la papelera (sin confirmación) ofrecen «Deshacer». Cada conversación con respuestas se resuelve («✓ Resolver», con una conclusión opcional que queda como decisión) y se pliega en una línea; la cabecera cuenta las abiertas y terminar el ticket con alguna pregunta antes. Se ve quién está escribiendo en cada conversación («Ana está escribiendo…», `PresenceFacade.typists`), y en las respuestas Enter envía (Shift+Enter, otra línea) | | `/p/:key/releases` | Releases | | `/p/:key/report/:kind/:id` | Informe para imprimir de un sprint (`sprint`) o una release (`release`): datos, totales, notas y tickets por etapa. «Imprimir o guardar en PDF» abre el diálogo del navegador; al imprimir se quitan la cabecera, las pestañas y los fondos (`styles.scss`) | | `/p/:key/roadmap` | Hoja de ruta: épicas en el tiempo, con sprints y releases; flechas «bloquea» / «depende de» entre épicas y aviso si una empieza antes de que acabe la que la bloquea | diff --git a/docs/09-mcp.md b/docs/09-mcp.md index 111adce..42aae51 100644 --- a/docs/09-mcp.md +++ b/docs/09-mcp.md @@ -67,6 +67,7 @@ flowchart LR | | `duplicate_issue` | Crear otro ticket con lo mismo (sin estado, persona ni comentarios) | | | `react_to_comment` | Reaccionar a un comentario (👍 🎉…) o quitar la reacción | | | `mark_decision` | Marcar un comentario como decisión del ticket, o quitarla | +| | `resolve_thread` | Resolver una conversación (con conclusión opcional, que queda como decisión) o reabrirla | | | `search_decisions` | Qué se decidió: decisiones por su texto, de un proyecto o de todos los míos | | | `add_checklist_item` / `update_checklist_item` / `remove_checklist_item` | La lista de comprobación: añadir, marcar o reescribir, quitar | | Organización | `list_epics` | Épicas con el progreso de lo que cuelga de cada una | diff --git a/libs/api/tracker/application/src/lib/changes.service.ts b/libs/api/tracker/application/src/lib/changes.service.ts index a671260..489cc1c 100644 --- a/libs/api/tracker/application/src/lib/changes.service.ts +++ b/libs/api/tracker/application/src/lib/changes.service.ts @@ -9,6 +9,7 @@ import { ProjectChange, ReleaseChange, SprintChange, + Typing, } from './ports/change-feed'; import { CanViewProjectQuery } from './queries/can-view-project.query'; import { GetIssueQuery } from './queries/get-issue.query'; @@ -53,6 +54,15 @@ export class ChangesService { return this.changeFeed.comments(issueId); } + /** Who is writing on the ticket (the client leaves itself out). */ + async watchTyping( + viewerId: string, + issueId: string, + ): Promise> { + await this.queryBus.execute(new GetIssueQuery(viewerId, issueId)); // access check + return this.changeFeed.typing(issueId); + } + async watchActivity( viewerId: string, projectId: string, 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 4c995e6..3ac462e 100644 --- a/libs/api/tracker/application/src/lib/commands/command-handlers.ts +++ b/libs/api/tracker/application/src/lib/commands/command-handlers.ts @@ -1,5 +1,6 @@ import { ReactToCommentCommandHandler } from './react-to-comment.command-handler'; import { MarkDecisionCommandHandler } from './mark-decision.command-handler'; +import { ResolveThreadCommandHandler } from './resolve-thread.command-handler'; import { VoteIssueCommandHandler } from './vote-issue.command-handler'; import { CreateProjectCommandHandler } from './create-project.command-handler'; import { UpdateProjectCommandHandler } from './update-project.command-handler'; @@ -48,6 +49,7 @@ export const COMMAND_HANDLERS = [ VoteIssueCommandHandler, ReactToCommentCommandHandler, MarkDecisionCommandHandler, + ResolveThreadCommandHandler, CreateProjectCommandHandler, UpdateProjectCommandHandler, UpdateProjectSettingsCommandHandler, 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 37c1388..8f53d00 100644 --- a/libs/api/tracker/application/src/lib/commands/commands.spec.ts +++ b/libs/api/tracker/application/src/lib/commands/commands.spec.ts @@ -78,6 +78,7 @@ import { AttachmentStorage } from '../ports/attachment-storage'; import { AddCommentCommand } from './add-comment.command'; import { ReactToCommentCommand } from './react-to-comment.command'; import { MarkDecisionCommand } from './mark-decision.command'; +import { ResolveThreadCommand } from './resolve-thread.command'; import { UpdateProjectCommand } from './update-project.command'; import { VoteIssueCommand } from './vote-issue.command'; import { AddProjectMemberCommand } from './add-project-member.command'; @@ -943,6 +944,51 @@ describe('tracker commands (event sourced)', () => { ); }); + it('settles a conversation, keeping its conclusion as a decision', async () => { + const { run, rehydrator, store } = await setup(); + const projectId = await projectWithTeam(run); + const issue = await run( + new CreateIssueCommand('dev', projectId, IssueType.TASK, 'Pagos', {}), + ); + const thread = await run( + new AddCommentCommand('qa', issue.id, '¿Stripe o Redsys?'), + ); + const reply = await run( + new AddCommentCommand('dev', issue.id, 'Stripe', thread.id), + ); + // A reply is not a conversation; a viewer does not settle one. + await expect( + run(new ResolveThreadCommand('dev', reply.id, true)), + ).rejects.toThrow(BusinessRuleError); + await expect( + run(new ResolveThreadCommand('pm', thread.id, true)), + ).rejects.toThrow(ForbiddenError); + + await run( + new ResolveThreadCommand('dev', thread.id, true, ' Vamos con Stripe '), + ); + expect((await rehydrator.rehydrate(thread.id, Comment)).resolved).toBe( + true, + ); + const conclusion = store.events.find( + (e) => + e.type === 'CommentAddedEvent' && + (e.data as { comment: { body: string } }).comment.body === + 'Vamos con Stripe', + ); + expect(conclusion).toBeDefined(); + const added = await rehydrator.rehydrate( + conclusion?.streamId ?? '', + Comment, + ); + expect(added).toMatchObject({ replyTo: thread.id, decision: true }); + + await run(new ResolveThreadCommand('owner', thread.id, false)); + expect((await rehydrator.rehydrate(thread.id, Comment)).resolved).toBe( + false, + ); + }); + it('takes votes from members, and from anyone once it is public', async () => { const { run, rehydrator } = await setup(); const projectId = await projectWithTeam(run); diff --git a/libs/api/tracker/application/src/lib/commands/resolve-thread.command-handler.ts b/libs/api/tracker/application/src/lib/commands/resolve-thread.command-handler.ts new file mode 100644 index 0000000..5b144c6 --- /dev/null +++ b/libs/api/tracker/application/src/lib/commands/resolve-thread.command-handler.ts @@ -0,0 +1,44 @@ +import { CommandHandler, EventPublisher, ICommandHandler } from '@nestjs/cqrs'; +import { BusinessRuleError } from '@task-stream/api/shared/domain'; +import { CommentFactory } from '@task-stream/api/tracker/domain'; +import { CommandResult, ack } from './command-result'; +import { ResolveThreadCommand } from './resolve-thread.command'; +import { WriteAccess } from './write-access'; + +/** The team settles its conversations: whoever can change the ticket. */ +@CommandHandler(ResolveThreadCommand) +export class ResolveThreadCommandHandler + implements ICommandHandler +{ + constructor( + private readonly access: WriteAccess, + private readonly commentFactory: CommentFactory, + private readonly eventPublisher: EventPublisher, + ) {} + + async execute(command: ResolveThreadCommand): Promise { + const thread = await this.access.comment(command.commentId); + if (thread.replyTo) + throw new BusinessRuleError( + 'Only a conversation is resolved, not a reply', + ); + await this.access.issueForContributor(thread.issueId, command.actorId); + const conclusion = command.conclusion?.trim(); + if (command.resolved && conclusion && !thread.resolved) { + // What was settled stays as a decision, shown above the ticket. + const reply = this.commentFactory.create({ + issueId: thread.issueId, + projectId: thread.projectId, + authorId: command.actorId, + body: conclusion, + replyTo: thread.id, + }); + reply.markDecision(command.actorId, true); + this.eventPublisher.mergeObjectContext(reply); + await reply.commitAndWait(); + } + thread.resolveThread(command.actorId, command.resolved); + await thread.commitAndWait(); + return ack(thread); + } +} diff --git a/libs/api/tracker/application/src/lib/commands/resolve-thread.command.ts b/libs/api/tracker/application/src/lib/commands/resolve-thread.command.ts new file mode 100644 index 0000000..a8a49fe --- /dev/null +++ b/libs/api/tracker/application/src/lib/commands/resolve-thread.command.ts @@ -0,0 +1,17 @@ +import { Command } from '@nestjs/cqrs'; +import { CommandResult } from './command-result'; + +/** + * Settles a conversation (`resolved`) or reopens it. A conclusion, if + * given, is added as a reply marked as a decision before it closes. + */ +export class ResolveThreadCommand extends Command { + constructor( + public readonly actorId: string, + public readonly commentId: string, + public readonly resolved: boolean, + public readonly conclusion: string | null = null, + ) { + super(); + } +} diff --git a/libs/api/tracker/application/src/lib/comments.service.ts b/libs/api/tracker/application/src/lib/comments.service.ts index 86c3989..23ba7b1 100644 --- a/libs/api/tracker/application/src/lib/comments.service.ts +++ b/libs/api/tracker/application/src/lib/comments.service.ts @@ -11,6 +11,7 @@ import { DeleteCommentCommand } from './commands/delete-comment.command'; import { EditCommentCommand } from './commands/edit-comment.command'; import { ReactToCommentCommand } from './commands/react-to-comment.command'; import { MarkDecisionCommand } from './commands/mark-decision.command'; +import { ResolveThreadCommand } from './commands/resolve-thread.command'; import { Page } from './ports/find-issues.repository'; import { GetCommentsQuery } from './queries/get-comments.query'; import { @@ -69,6 +70,21 @@ export class CommentsService { ); } + /** + * Settles a conversation or reopens it; a conclusion stays as a reply + * marked as a decision. + */ + resolveThread( + actorId: string, + commentId: string, + resolved: boolean, + conclusion: string | null = null, + ): Promise { + return this.commands.execute( + new ResolveThreadCommand(actorId, commentId, resolved, conclusion), + ); + } + /** A ticket's decisions, oldest first. */ decisions( viewerId: string | null, diff --git a/libs/api/tracker/application/src/lib/event-handlers/comment-added.event-handler.ts b/libs/api/tracker/application/src/lib/event-handlers/comment-added.event-handler.ts index 9037b82..c9ef3ae 100644 --- a/libs/api/tracker/application/src/lib/event-handlers/comment-added.event-handler.ts +++ b/libs/api/tracker/application/src/lib/event-handlers/comment-added.event-handler.ts @@ -33,6 +33,9 @@ export class CommentAddedEventHandler decision: false, decidedBy: null, decidedAt: null, + resolved: false, + resolvedBy: null, + resolvedAt: null, createdAt: comment.createdAt, editedAt: null, version: versionOf(event), diff --git a/libs/api/tracker/application/src/lib/event-handlers/comment-thread-resolved.event-handler.ts b/libs/api/tracker/application/src/lib/event-handlers/comment-thread-resolved.event-handler.ts new file mode 100644 index 0000000..c9b9a7d --- /dev/null +++ b/libs/api/tracker/application/src/lib/event-handlers/comment-thread-resolved.event-handler.ts @@ -0,0 +1,28 @@ +import { EventsHandler, IEventHandler } from '@nestjs/cqrs'; +import { CommentThreadResolvedEvent } from '@task-stream/api/tracker/domain'; +import { UpsertMaterializedCommentRepository } from '../ports/upsert-materialized-comment.repository'; +import { UserDirectory } from '../ports/user-directory'; +import { ReadModelNotifier, versionOf } from './read-model-notifier'; + +@EventsHandler(CommentThreadResolvedEvent) +export class CommentThreadResolvedEventHandler + implements IEventHandler +{ + constructor( + private readonly upsertMaterializedCommentRepository: UpsertMaterializedCommentRepository, + private readonly users: UserDirectory, + private readonly notifier: ReadModelNotifier, + ) {} + + async handle(event: CommentThreadResolvedEvent) { + const refs = await this.users.refs([event.actorId]); + await this.upsertMaterializedCommentRepository.upsert({ + id: event.aggregateId, + resolved: event.resolved, + resolvedBy: event.resolved ? refs.of(event.actorId) : null, + resolvedAt: event.resolved ? event.occurredAt : null, + version: versionOf(event), + }); + await this.notifier.comment(event.aggregateId, event.issueId, event); + } +} 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 fcc4742..6d09952 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 @@ -4,6 +4,7 @@ import { CommentDeletedEventHandler } from './comment-deleted.event-handler'; import { CommentEditedEventHandler } from './comment-edited.event-handler'; import { CommentReactedEventHandler } from './comment-reacted.event-handler'; import { CommentDecisionMarkedEventHandler } from './comment-decision-marked.event-handler'; +import { CommentThreadResolvedEventHandler } from './comment-thread-resolved.event-handler'; import { IssueVotedEventHandler } from './issue-voted.event-handler'; import { IssueAssignedEventHandler } from './issue-assigned.event-handler'; import { IssueCreatedEventHandler } from './issue-created.event-handler'; @@ -52,6 +53,7 @@ export const EVENT_HANDLERS = [ CommentEditedEventHandler, CommentReactedEventHandler, CommentDecisionMarkedEventHandler, + CommentThreadResolvedEventHandler, IssueVotedEventHandler, IssueAssignedEventHandler, IssueCreatedEventHandler, diff --git a/libs/api/tracker/application/src/lib/ports/change-feed.ts b/libs/api/tracker/application/src/lib/ports/change-feed.ts index de66e49..b87ed9d 100644 --- a/libs/api/tracker/application/src/lib/ports/change-feed.ts +++ b/libs/api/tracker/application/src/lib/ports/change-feed.ts @@ -46,6 +46,15 @@ export interface ProjectChange extends Change { * Live stream of read-model changes: event handlers publish once the read * model is up to date, GraphQL subscriptions watch. */ +/** Someone is writing on a ticket: never stored, gone in a few seconds. */ +export interface Typing { + issueId: string; + user: { id: string; name: string }; + /** The conversation being answered; null: a new comment. */ + threadId: string | null; + at: string; +} + export abstract class ChangeFeed { abstract publishIssue(change: IssueChange): Promise; abstract publishComment(change: CommentChange): Promise; @@ -64,6 +73,8 @@ export abstract class ChangeFeed { abstract issues(projectId: string): AsyncIterableIterator; abstract comments(issueId: string): AsyncIterableIterator; + abstract publishTyping(typing: Typing): Promise; + abstract typing(issueId: string): AsyncIterableIterator; abstract activity( projectId: string, ): AsyncIterableIterator; diff --git a/libs/api/tracker/application/src/lib/presence.service.spec.ts b/libs/api/tracker/application/src/lib/presence.service.spec.ts index a6f6e71..4c59114 100644 --- a/libs/api/tracker/application/src/lib/presence.service.spec.ts +++ b/libs/api/tracker/application/src/lib/presence.service.spec.ts @@ -1,5 +1,6 @@ import { ForbiddenError, NotFoundError } from '@task-stream/api/shared/domain'; import { IssueReadModel } from '@task-stream/api/tracker/domain'; +import { ChangeFeed, Typing } from './ports/change-feed'; import { FindIssuesRepository } from './ports/find-issues.repository'; import { PresenceEntry, PresenceStore } from './ports/presence.store'; import { PRESENCE_TTL_MS, PresenceService } from './presence.service'; @@ -40,10 +41,23 @@ const access = { }, } as unknown as ReadAccess; +/** What would go out to the ticket's open pages. */ +const feed = () => { + const published: Typing[] = []; + return { + published, + feed: { + publishTyping: async (typing: Typing) => { + published.push(typing); + }, + } as unknown as ChangeFeed, + }; +}; + describe('PresenceService', () => { it('tells who else is on a ticket, the ones editing first', async () => { const store = new MemoryPresence(); - const presence = new PresenceService(store, issues, access); + const presence = new PresenceService(store, issues, access, feed().feed); await presence.touch({ id: 'ana', name: 'Ana' }, 'i1', false); await presence.touch({ id: 'cris', name: 'Cris' }, 'i1', true); const seen = await presence.touch({ id: 'bob', name: 'Bob' }, 'i1', false); @@ -62,8 +76,33 @@ describe('PresenceService', () => { ).toEqual([]); }); + it('tells at once who is writing, and where, to whoever can read it', async () => { + const { published, feed: changeFeed } = feed(); + const presence = new PresenceService( + new MemoryPresence(), + issues, + access, + changeFeed, + ); + await presence.typing({ id: 'ana', name: 'Ana' }, 'i1', 'c1'); + await presence.typing({ id: 'bob', name: 'Bob' }, 'i1', null); + expect(published.map((t) => [t.issueId, t.user.name, t.threadId])).toEqual([ + ['i1', 'Ana', 'c1'], + ['i1', 'Bob', null], + ]); + await expect( + presence.typing({ id: 'eve', name: 'Eve' }, 'i1', null), + ).rejects.toBeInstanceOf(ForbiddenError); + expect(published).toHaveLength(2); + }); + it('only for whoever can read the ticket', async () => { - const presence = new PresenceService(new MemoryPresence(), issues, access); + const presence = new PresenceService( + new MemoryPresence(), + issues, + access, + feed().feed, + ); await expect( presence.touch({ id: 'eve', name: 'Eve' }, 'i1', false), ).rejects.toBeInstanceOf(ForbiddenError); diff --git a/libs/api/tracker/application/src/lib/presence.service.ts b/libs/api/tracker/application/src/lib/presence.service.ts index 62292dc..d0e1d8b 100644 --- a/libs/api/tracker/application/src/lib/presence.service.ts +++ b/libs/api/tracker/application/src/lib/presence.service.ts @@ -1,5 +1,6 @@ import { Injectable } from '@nestjs/common'; import { NotFoundError } from '@task-stream/api/shared/domain'; +import { ChangeFeed } from './ports/change-feed'; import { FindIssuesRepository } from './ports/find-issues.repository'; import { PresenceEntry, PresenceStore } from './ports/presence.store'; import { ReadAccess } from './queries/read-access'; @@ -17,6 +18,7 @@ export class PresenceService { private readonly store: PresenceStore, private readonly findIssues: FindIssuesRepository, private readonly access: ReadAccess, + private readonly changeFeed: ChangeFeed, ) {} /** @@ -54,6 +56,24 @@ export class PresenceService { ); } + /** + * I am writing on the ticket (an answer to `threadId`, or a new comment): + * told at once to whoever has it open, and forgotten. + */ + async typing( + viewer: { id: string; name: string }, + issueId: string, + threadId: string | null, + ): Promise { + await this.assertCanView(viewer.id, issueId); + await this.changeFeed.publishTyping({ + issueId, + user: { id: viewer.id, name: viewer.name }, + threadId, + at: new Date().toISOString(), + }); + } + /** I left the ticket's page. */ leave(viewerId: string, issueId: string): Promise { return this.store.leave(issueId, viewerId); diff --git a/libs/api/tracker/domain/src/lib/comment/comment.ts b/libs/api/tracker/domain/src/lib/comment/comment.ts index 708678b..2aef0d6 100644 --- a/libs/api/tracker/domain/src/lib/comment/comment.ts +++ b/libs/api/tracker/domain/src/lib/comment/comment.ts @@ -6,6 +6,7 @@ import { } from '@task-stream/api/shared/domain'; import { CommentAddedEvent } from '../events/comment-added.event'; import { CommentDecisionMarkedEvent } from '../events/comment-decision-marked.event'; +import { CommentThreadResolvedEvent } from '../events/comment-thread-resolved.event'; import { CommentDeletedEvent } from '../events/comment-deleted.event'; import { CommentEditedEvent } from '../events/comment-edited.event'; import { CommentReactedEvent } from '../events/comment-reacted.event'; @@ -44,6 +45,8 @@ export class Comment extends VersionedAggregateRoot implements CommentProps { public reactions: Record = {}; /** What was decided on the ticket: shown above it and searchable. */ public decision = false; + /** A top-level comment whose conversation was settled. */ + public resolved = false; public deleted = false; public createdAt!: string; public editedAt: string | null = null; @@ -114,6 +117,28 @@ export class Comment extends VersionedAggregateRoot implements CommentProps { ); } + /** + * Settles the conversation this comment opens (`on`) or reopens it; + * repeating it changes nothing. Only a top-level comment opens one. + */ + resolveThread(actorId: string, on: boolean): void { + this.assertAlive(); + if (this.replyTo) + throw new BusinessRuleError( + 'Only a conversation is resolved, not a reply', + ); + if (on === this.resolved) return; + this.apply( + new CommentThreadResolvedEvent( + actorId, + this.id, + this.projectId, + this.issueId, + on, + ), + ); + } + /** @param canModerate whether the actor manages the project (may delete others' comments). */ delete(actorId: string, canModerate: boolean): void { this.assertAlive(); @@ -146,6 +171,10 @@ export class Comment extends VersionedAggregateRoot implements CommentProps { this.decision = event.decision; } + onCommentThreadResolvedEvent(event: CommentThreadResolvedEvent): void { + this.resolved = event.resolved; + } + onCommentDeletedEvent(): void { this.deleted = true; } diff --git a/libs/api/tracker/domain/src/lib/events/comment-thread-resolved.event.ts b/libs/api/tracker/domain/src/lib/events/comment-thread-resolved.event.ts new file mode 100644 index 0000000..e630c55 --- /dev/null +++ b/libs/api/tracker/domain/src/lib/events/comment-thread-resolved.event.ts @@ -0,0 +1,17 @@ +import { AutowiredEvent } from '@task-stream/api/shared/domain'; +import { TrackerEvent } from './tracker.event'; + +/** A conversation (a top-level comment and its replies) was settled, or reopened. */ +@AutowiredEvent +export class CommentThreadResolvedEvent extends TrackerEvent { + constructor( + actorId: string, + commentId: string, + projectId: string, + readonly issueId: string, + /** False: reopened. */ + readonly resolved: boolean, + ) { + super(actorId, commentId, projectId); + } +} diff --git a/libs/api/tracker/domain/src/lib/events/index.ts b/libs/api/tracker/domain/src/lib/events/index.ts index e6793ff..3d5de21 100644 --- a/libs/api/tracker/domain/src/lib/events/index.ts +++ b/libs/api/tracker/domain/src/lib/events/index.ts @@ -16,6 +16,7 @@ export * from './comment-added.event'; export * from './comment-edited.event'; export * from './comment-reacted.event'; export * from './comment-decision-marked.event'; +export * from './comment-thread-resolved.event'; export * from './comment-deleted.event'; export * from './release-created.event'; export * from './release-updated.event'; diff --git a/libs/api/tracker/domain/src/lib/read-models/comment.read-model.ts b/libs/api/tracker/domain/src/lib/read-models/comment.read-model.ts index 270de9e..dd40704 100644 --- a/libs/api/tracker/domain/src/lib/read-models/comment.read-model.ts +++ b/libs/api/tracker/domain/src/lib/read-models/comment.read-model.ts @@ -26,6 +26,11 @@ export class CommentReadModel { /** Who marked it and when (null when it is not a decision). */ decidedBy!: UserRefReadModel | null; decidedAt!: string | null; + /** A top-level comment whose conversation was settled. */ + resolved!: boolean; + /** Who settled it and when (null while open). */ + resolvedBy!: UserRefReadModel | null; + resolvedAt!: string | null; createdAt!: string; editedAt!: string | null; version!: number; diff --git a/libs/api/tracker/infrastructure/src/lib/mongo/find.repositories.ts b/libs/api/tracker/infrastructure/src/lib/mongo/find.repositories.ts index a484419..ed2353e 100644 --- a/libs/api/tracker/infrastructure/src/lib/mongo/find.repositories.ts +++ b/libs/api/tracker/infrastructure/src/lib/mongo/find.repositories.ts @@ -54,6 +54,9 @@ const commentFromDoc = (doc: CommentDoc): CommentReadModel => ({ decision: doc.decision ?? false, decidedBy: doc.decidedBy ?? null, decidedAt: doc.decidedAt ?? null, + resolved: doc.resolved ?? false, + resolvedBy: doc.resolvedBy ?? null, + resolvedAt: doc.resolvedAt ?? null, }); const escapeRegex = (text: string) => diff --git a/libs/api/tracker/infrastructure/src/lib/realtime/redis-change-feed.ts b/libs/api/tracker/infrastructure/src/lib/realtime/redis-change-feed.ts index 0c40710..5102aa2 100644 --- a/libs/api/tracker/infrastructure/src/lib/realtime/redis-change-feed.ts +++ b/libs/api/tracker/infrastructure/src/lib/realtime/redis-change-feed.ts @@ -7,6 +7,7 @@ import { ProjectChange, ReleaseChange, SprintChange, + Typing, } from '@task-stream/api/tracker/application'; import { ActivityReadModel, @@ -18,6 +19,7 @@ import { RedisPubSub } from 'graphql-redis-subscriptions'; const Channels = { issue: (projectId: string) => `tracker.issue.${projectId}`, comment: (issueId: string) => `tracker.comment.${issueId}`, + typing: (issueId: string) => `tracker.typing.${issueId}`, activity: (projectId: string) => `tracker.activity.${projectId}`, release: (projectId: string) => `tracker.release.${projectId}`, sprint: (projectId: string) => `tracker.sprint.${projectId}`, @@ -86,6 +88,14 @@ export class RedisChangeFeed extends ChangeFeed { return this.pubSub.asyncIterableIterator(Channels.comment(issueId)); } + publishTyping(typing: Typing): Promise { + return this.pubSub.publish(Channels.typing(typing.issueId), typing); + } + + typing(issueId: string): AsyncIterableIterator { + return this.pubSub.asyncIterableIterator(Channels.typing(issueId)); + } + activity(projectId: string): AsyncIterableIterator { return this.pubSub.asyncIterableIterator(Channels.activity(projectId)); } diff --git a/libs/api/tracker/presenters/src/lib/graphql/comment.resolver.ts b/libs/api/tracker/presenters/src/lib/graphql/comment.resolver.ts index 120ab5a..d87d447 100644 --- a/libs/api/tracker/presenters/src/lib/graphql/comment.resolver.ts +++ b/libs/api/tracker/presenters/src/lib/graphql/comment.resolver.ts @@ -59,6 +59,24 @@ export class CommentResolver { return this.comments.markDecision(me.id, commentId, on); } + @Mutation(() => CommandResultGql, { + description: + 'Settle a conversation (a top-level comment and its replies) or reopen it (resolved: false). A conclusion is added as a reply marked as a decision. Whoever can change the ticket', + }) + resolveThread( + @CurrentUser() me: User, + @Args('commentId', { type: () => ID }) commentId: string, + @Args('resolved', { defaultValue: true }) resolved: boolean, + @Args('conclusion', { nullable: true }) conclusion: string | null, + ) { + return this.comments.resolveThread( + me.id, + commentId, + resolved, + conclusion ?? null, + ); + } + @OptionalAuth() @Query(() => [DecisionGql], { description: 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 df993b1..1dfef3d 100644 --- a/libs/api/tracker/presenters/src/lib/graphql/issue.resolver.ts +++ b/libs/api/tracker/presenters/src/lib/graphql/issue.resolver.ts @@ -77,6 +77,28 @@ export class IssueResolver { return this.presence.touch({ id: me.id, name: me.name }, issueId, editing); } + @Mutation(() => Boolean, { + description: + 'I am writing on this ticket (send every few seconds while typing): those with it open see it at once via `typing`', + }) + async typing( + @CurrentUser() me: User, + @Args('issueId', { type: () => ID }) issueId: string, + @Args('threadId', { + type: () => ID, + nullable: true, + description: 'The conversation being answered; null: a new comment', + }) + threadId: string | null, + ) { + await this.presence.typing( + { id: me.id, name: me.name }, + issueId, + threadId ?? null, + ); + return true; + } + @Mutation(() => Boolean, { description: "I left this ticket's page" }) async leavePresence( @CurrentUser() me: User, diff --git a/libs/api/tracker/presenters/src/lib/graphql/subscription.resolver.ts b/libs/api/tracker/presenters/src/lib/graphql/subscription.resolver.ts index 30149c3..99d01b2 100644 --- a/libs/api/tracker/presenters/src/lib/graphql/subscription.resolver.ts +++ b/libs/api/tracker/presenters/src/lib/graphql/subscription.resolver.ts @@ -13,6 +13,7 @@ import { ProjectChangeGql, ReleaseChangeGql, SprintChangeGql, + TypingGql, } from './types'; const asIs = { resolve: (payload: T) => payload }; @@ -48,6 +49,18 @@ export class SubscriptionResolver { return this.changes.watchComments(me.id, issueId); } + @Subscription(() => TypingGql, { + ...asIs, + description: + "Who is writing on a ticket, as they type (one's own come too: leave them out)", + }) + typing( + @CurrentUser() me: User, + @Args('issueId', { type: () => ID }) issueId: string, + ) { + return this.changes.watchTyping(me.id, issueId); + } + @Subscription(() => ActivityGql, { ...asIs, description: 'Live activity feed', diff --git a/libs/api/tracker/presenters/src/lib/graphql/types.ts b/libs/api/tracker/presenters/src/lib/graphql/types.ts index a062891..39d2989 100644 --- a/libs/api/tracker/presenters/src/lib/graphql/types.ts +++ b/libs/api/tracker/presenters/src/lib/graphql/types.ts @@ -562,6 +562,17 @@ export class CommentGql { }) decidedBy!: UserRefGql | null; @Field(() => String, { nullable: true }) decidedAt!: string | null; + @Field({ + description: + 'A conversation (top-level comment and its replies) that was settled', + }) + resolved!: boolean; + @Field(() => UserRefGql, { + nullable: true, + description: 'Who settled it', + }) + resolvedBy!: UserRefGql | null; + @Field(() => String, { nullable: true }) resolvedAt!: string | null; @Field() createdAt!: string; @Field(() => String, { nullable: true }) editedAt!: string | null; @Field(() => Int) version!: number; @@ -1157,6 +1168,20 @@ export class ProjectInsightsGql { proposals!: ProposalsFunnelGql | null; } +@ObjectType('Typing', { + description: 'Someone writing on a ticket right now (not stored)', +}) +export class TypingGql { + @Field(() => ID) issueId!: string; + @Field(() => UserRefGql) user!: UserRefGql; + @Field(() => ID, { + nullable: true, + description: 'The conversation being answered; null: a new comment', + }) + threadId!: string | null; + @Field() at!: string; +} + @ObjectType('Presence', { description: 'Someone on a ticket right now (heartbeat in the last 30 s)', }) diff --git a/libs/api/tracker/presenters/src/lib/http/comments.controller.ts b/libs/api/tracker/presenters/src/lib/http/comments.controller.ts index 7eba227..ceca720 100644 --- a/libs/api/tracker/presenters/src/lib/http/comments.controller.ts +++ b/libs/api/tracker/presenters/src/lib/http/comments.controller.ts @@ -17,7 +17,11 @@ import { import { ApiCommandErrors } from '@task-stream/api/shared/presenters'; import { CommentsService } from '@task-stream/api/tracker/application'; import { REACTIONS } from '@task-stream/api/tracker/domain'; -import { CommentBodyDto, ReactToCommentDto } from './dto/requests.dto'; +import { + CommentBodyDto, + ReactToCommentDto, + ResolveThreadDto, +} from './dto/requests.dto'; import { CommandResultDto } from './dto/responses.dto'; /** REST adapter of the comment use cases (listing and adding live under /issues). */ @@ -78,6 +82,35 @@ export class CommentsController { return this.comments.markDecision(me.id, commentId, false); } + @Put(':commentId/resolution') + @ApiOperation({ + summary: 'Settle a conversation (a top-level comment and its replies)', + description: + 'Whoever can change the ticket. A conclusion is added as a reply marked as a decision. The web folds a settled conversation.', + }) + @ApiOkResponse({ type: CommandResultDto }) + @ApiCommandErrors() + resolveThread( + @CurrentUser() me: User, + @Param('commentId') commentId: string, + @Body() body: ResolveThreadDto, + ) { + return this.comments.resolveThread( + me.id, + commentId, + true, + body.conclusion ?? null, + ); + } + + @Delete(':commentId/resolution') + @ApiOperation({ summary: 'Reopen a conversation' }) + @ApiOkResponse({ type: CommandResultDto }) + @ApiCommandErrors() + reopenThread(@CurrentUser() me: User, @Param('commentId') commentId: string) { + return this.comments.resolveThread(me.id, commentId, false); + } + @Delete(':commentId') @ApiOperation({ summary: 'Delete a comment (author or project admin)' }) @ApiOkResponse({ type: CommandResultDto }) diff --git a/libs/api/tracker/presenters/src/lib/http/dto/requests.dto.ts b/libs/api/tracker/presenters/src/lib/http/dto/requests.dto.ts index b65ade7..235e038 100644 --- a/libs/api/tracker/presenters/src/lib/http/dto/requests.dto.ts +++ b/libs/api/tracker/presenters/src/lib/http/dto/requests.dto.ts @@ -1552,6 +1552,18 @@ export class PushEndpointDto { endpoint!: string; } +export class ResolveThreadDto { + @ApiPropertyOptional({ + description: + 'What was settled: added as a reply marked as a decision before it closes', + maxLength: 20000, + }) + @IsOptional() + @IsString() + @MaxLength(20000) + conclusion?: string; +} + // ---- Alerts → bugs ---------------------------------------------------------- export class AlertSettingsDto { 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 e559497..cf6a0eb 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 @@ -642,6 +642,19 @@ export class CommentDto { decidedBy!: UserRefDto | null; @ApiProperty({ type: String, nullable: true, format: 'date-time' }) decidedAt!: string | null; + @ApiProperty({ + description: + 'A conversation (top-level comment and its replies) that was settled', + }) + resolved!: boolean; + @ApiProperty({ + type: UserRefDto, + nullable: true, + description: 'Who settled it', + }) + resolvedBy!: UserRefDto | null; + @ApiProperty({ type: String, nullable: true, format: 'date-time' }) + resolvedAt!: string | null; @ApiProperty({ format: 'date-time' }) createdAt!: string; @ApiProperty({ type: String, nullable: true, format: 'date-time' }) editedAt!: string | null; 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 a97a286..1366f33 100644 --- a/libs/web/tracker/application/src/lib/events/issue.events.ts +++ b/libs/web/tracker/application/src/lib/events/issue.events.ts @@ -81,6 +81,11 @@ export const issuePageEvents = eventGroup({ commentEditRequested: type<{ commentId: string; body: string }>(), reactRequested: type<{ commentId: string; emoji: string; on: boolean }>(), decisionRequested: type<{ commentId: string; on: boolean }>(), + threadResolveRequested: type<{ + commentId: string; + resolved: boolean; + conclusion: string | null; + }>(), voteRequested: type<{ issueId: string; on: boolean }>(), /** An image pasted into a text was uploaded (it joins the ticket's files). */ imagePasted: type<{ issueId: string }>(), diff --git a/libs/web/tracker/application/src/lib/events/presence.events.ts b/libs/web/tracker/application/src/lib/events/presence.events.ts index 3b64e75..f610d03 100644 --- a/libs/web/tracker/application/src/lib/events/presence.events.ts +++ b/libs/web/tracker/application/src/lib/events/presence.events.ts @@ -1,12 +1,14 @@ import { type } from '@ngrx/signals'; import { eventGroup } from '@ngrx/signals/events'; -import { Presence } from '@task-stream/web/tracker/domain'; +import { Presence, Typing } from '@task-stream/web/tracker/domain'; export const presencePageEvents = eventGroup({ source: 'Issue Presence', events: { opened: type(), editingChanged: type(), + /** I typed in an answer to this conversation (null: a new comment). */ + typed: type(), left: type(), }, }); @@ -15,5 +17,9 @@ export const presenceApiEvents = eventGroup({ source: 'Presence API', events: { seen: type(), + /** Someone else is writing. */ + typing: type(), + /** …and stopped a while ago (the `at` it was last seen). */ + typingStopped: type<{ userId: string; at: string }>(), }, }); 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 026c355..fbee666 100644 --- a/libs/web/tracker/application/src/lib/facades/issue.facade.ts +++ b/libs/web/tracker/application/src/lib/facades/issue.facade.ts @@ -231,6 +231,22 @@ export class IssueFacade { this.dispatch.reactRequested({ commentId, emoji, on }); } + /** + * Settles a conversation (or reopens it); a conclusion is added as a + * reply marked as a decision. + */ + resolveThread( + commentId: string, + resolved: boolean, + conclusion: string | null = null, + ): void { + this.dispatch.threadResolveRequested({ + commentId, + resolved, + conclusion: conclusion?.trim() || null, + }); + } + /** Marks a comment as a decision of the ticket (`on`) or unmarks it. */ markDecision(commentId: string, on: boolean): void { this.dispatch.decisionRequested({ commentId, on }); diff --git a/libs/web/tracker/application/src/lib/facades/presence.facade.ts b/libs/web/tracker/application/src/lib/facades/presence.facade.ts index 896c6fc..c647136 100644 --- a/libs/web/tracker/application/src/lib/facades/presence.facade.ts +++ b/libs/web/tracker/application/src/lib/facades/presence.facade.ts @@ -11,6 +11,8 @@ export class PresenceFacade { /** The others, the ones editing first. */ readonly others = this.store.others; + /** Who else is writing now, and where (`threadId` null: a new comment). */ + readonly typists = this.store.typists; /** I am on this ticket: beats until the page closes. */ open(issueId: string): void { @@ -20,6 +22,11 @@ export class PresenceFacade { setEditing(editing: boolean): void { this.dispatch.editingChanged(editing); } + + /** I am writing an answer to `threadId` (null: a new comment). */ + typed(threadId: string | null): void { + this.dispatch.typed(threadId); + } } /** Store + facade of a ticket's presence, for the ticket's page. */ 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 df7bc59..8ad4ae4 100644 --- a/libs/web/tracker/application/src/lib/stores/issue.store.ts +++ b/libs/web/tracker/application/src/lib/stores/issue.store.ts @@ -479,6 +479,26 @@ export const IssueStore = signalStore( ), ), ), + events + .on(issuePageEvents.threadResolveRequested) + .pipe( + mergeMap(({ payload }) => + runCommand( + comments.resolveThread( + payload.commentId, + payload.resolved, + payload.conclusion, + ), + !payload.resolved + ? 'Conversación reabierta' + : payload.conclusion + ? 'Resuelta: la conclusión queda como decisión' + : 'Conversación resuelta', + done, + failed, + ), + ), + ), events.on(issuePageEvents.reactRequested).pipe( mergeMap(({ payload }) => comments.react(payload.commentId, payload.emoji, payload.on).pipe( diff --git a/libs/web/tracker/application/src/lib/stores/presence.store.ts b/libs/web/tracker/application/src/lib/stores/presence.store.ts index f2a0307..9a4963c 100644 --- a/libs/web/tracker/application/src/lib/stores/presence.store.ts +++ b/libs/web/tracker/application/src/lib/stores/presence.store.ts @@ -6,18 +6,26 @@ import { withEventHandlers, withReducer, } from '@ngrx/signals/events'; -import { Presence, PresenceGateway } from '@task-stream/web/tracker/domain'; +import { AuthFacade } from '@task-stream/web/identity/application'; +import { + Presence, + PresenceGateway, + Typing, +} from '@task-stream/web/tracker/domain'; import { catchError, combineLatest, distinctUntilChanged, EMPTY, + filter, + groupBy, map, mergeMap, pairwise, startWith, switchMap, takeUntil, + throttleTime, timer, } from 'rxjs'; import { @@ -27,10 +35,16 @@ import { /** A heartbeat every so often; the API forgets a page after 30 s without one. */ export const PRESENCE_BEAT_MS = 10_000; +/** While typing, I say so at most this often… */ +export const TYPING_EVERY_MS = 3_000; +/** …and someone silent this long has stopped. */ +export const TYPING_GONE_MS = 6_000; interface PresenceState { issueId: string | null; others: Presence[]; + /** Who else is writing now, one entry each. */ + typists: Typing[]; } /** @@ -39,17 +53,75 @@ interface PresenceState { * to the ticket's page. */ export const PresenceStore = signalStore( - withState({ issueId: null, others: [] }), + withState({ issueId: null, others: [], typists: [] }), withReducer( on(presencePageEvents.opened, ({ payload }) => ({ issueId: payload, others: [], + typists: [], })), on(presenceApiEvents.seen, ({ payload }) => ({ others: payload })), - on(presencePageEvents.left, () => ({ issueId: null, others: [] })), + on(presenceApiEvents.typing, ({ payload }, state) => ({ + typists: [ + ...state.typists.filter((t) => t.user.id !== payload.user.id), + payload, + ], + })), + on(presenceApiEvents.typingStopped, ({ payload }, state) => ({ + typists: state.typists.filter( + (t) => !(t.user.id === payload.userId && t.at === payload.at), + ), + })), + on(presencePageEvents.left, () => ({ + issueId: null, + others: [], + typists: [], + })), ), withEventHandlers( - (store, events = inject(Events), presence = inject(PresenceGateway)) => ({ + ( + store, + events = inject(Events), + presence = inject(PresenceGateway), + auth = inject(AuthFacade), + ) => ({ + // Each answer (or the new comment) tells the others at most every 3 s. + typed$: events.on(presencePageEvents.typed).pipe( + map(({ payload }) => payload), + groupBy((threadId) => threadId ?? ''), + mergeMap((thread$) => thread$.pipe(throttleTime(TYPING_EVERY_MS))), + mergeMap((threadId) => { + const issueId = store.issueId(); + // Telling is a nicety: nothing to say if it fails. + return issueId + ? presence.typing(issueId, threadId).pipe(catchError(() => EMPTY)) + : EMPTY; + }), + switchMap(() => EMPTY), + ), + typists$: events.on(presencePageEvents.opened).pipe( + switchMap(({ payload: issueId }) => + presence.typists(issueId).pipe( + filter((typing) => typing.user.id !== auth.user()?.id), + map((typing) => presenceApiEvents.typing(typing)), + catchError(() => EMPTY), + takeUntil(events.on(presencePageEvents.left)), + ), + ), + ), + // Silent for a while: stopped (unless a newer one came meanwhile). + typingStopped$: events.on(presenceApiEvents.typing).pipe( + mergeMap(({ payload }) => + timer(TYPING_GONE_MS).pipe( + map(() => + presenceApiEvents.typingStopped({ + userId: payload.user.id, + at: payload.at, + }), + ), + ), + ), + ), beat$: events.on(presencePageEvents.opened).pipe( switchMap(({ payload: issueId }) => combineLatest([ diff --git a/libs/web/tracker/domain/src/lib/models/models.ts b/libs/web/tracker/domain/src/lib/models/models.ts index a99c461..3cd0e56 100644 --- a/libs/web/tracker/domain/src/lib/models/models.ts +++ b/libs/web/tracker/domain/src/lib/models/models.ts @@ -202,6 +202,14 @@ export interface Issue extends IssueSummary { } /** Someone else on a ticket's page right now. */ +/** Someone writing on a ticket right now. */ +export interface Typing { + user: UserRef; + /** The conversation being answered; null: a new comment. */ + threadId: string | null; + at: string; +} + export interface Presence { user: UserRef; /** Editing the ticket or writing a comment. */ @@ -342,6 +350,10 @@ export interface Comment { decision: boolean; /** Who marked it as a decision (null when it is not one). */ decidedBy: UserRef | null; + /** A conversation (top-level comment and its replies) that was settled. */ + resolved: boolean; + /** Who settled it (null while open). */ + resolvedBy: UserRef | null; createdAt: string; editedAt: string | null; } diff --git a/libs/web/tracker/domain/src/lib/ports/gateways.ts b/libs/web/tracker/domain/src/lib/ports/gateways.ts index 3188296..563130f 100644 --- a/libs/web/tracker/domain/src/lib/ports/gateways.ts +++ b/libs/web/tracker/domain/src/lib/ports/gateways.ts @@ -19,6 +19,7 @@ import { AlertIntegration, Comment, Decision, + Typing, Epic, GitIntegration, GitRepository, @@ -313,6 +314,15 @@ export abstract class CommentsGateway { on: boolean, ): Observable; abstract remove(commentId: string): Observable; + /** + * Settles a conversation (or reopens it); a conclusion is added as a + * reply marked as a decision. + */ + abstract resolveThread( + commentId: string, + resolved: boolean, + conclusion: string | null, + ): Observable; /** Marks it as a decision of its ticket (`on`) or unmarks it. */ abstract markDecision( commentId: string, @@ -496,4 +506,8 @@ export abstract class TrackerRealtimeGateway { export abstract class PresenceGateway { abstract touch(issueId: string, editing: boolean): Observable; abstract leave(issueId: string): Observable; + /** I am writing (an answer to `threadId`, or a new comment). */ + abstract typing(issueId: string, threadId: string | null): Observable; + /** Who is writing on the ticket, as they type (me included). */ + abstract typists(issueId: string): Observable; } diff --git a/libs/web/tracker/infrastructure/src/lib/adapters/graphql-collaboration.gateways.ts b/libs/web/tracker/infrastructure/src/lib/adapters/graphql-collaboration.gateways.ts index a3b2a3a..3e75021 100644 --- a/libs/web/tracker/infrastructure/src/lib/adapters/graphql-collaboration.gateways.ts +++ b/libs/web/tracker/infrastructure/src/lib/adapters/graphql-collaboration.gateways.ts @@ -24,6 +24,7 @@ import { IssueCommentsDocument, ReactToCommentDocument, MarkDecisionDocument, + ResolveThreadDocument, PublishReleaseDocument, ReleaseBurnupDocument, ReleaseNotesDraftDocument, @@ -77,6 +78,16 @@ export class GraphqlCommentsGateway extends CommentsGateway { .pipe(map((d) => toCommandResult(d.deleteComment))); } + resolveThread( + commentId: string, + resolved: boolean, + conclusion: string | null, + ): Observable { + return this.gql + .mutate(ResolveThreadDocument, { commentId, resolved, conclusion }) + .pipe(map((d) => toCommandResult(d.resolveThread))); + } + markDecision(commentId: string, on: boolean): Observable { return this.gql .mutate(MarkDecisionDocument, { commentId, on }) diff --git a/libs/web/tracker/infrastructure/src/lib/adapters/graphql-presence.gateway.ts b/libs/web/tracker/infrastructure/src/lib/adapters/graphql-presence.gateway.ts index 2fc63c2..8625a51 100644 --- a/libs/web/tracker/infrastructure/src/lib/adapters/graphql-presence.gateway.ts +++ b/libs/web/tracker/infrastructure/src/lib/adapters/graphql-presence.gateway.ts @@ -1,9 +1,15 @@ import { inject, Injectable } from '@angular/core'; -import { Presence, PresenceGateway } from '@task-stream/web/tracker/domain'; +import { + Presence, + PresenceGateway, + Typing, +} from '@task-stream/web/tracker/domain'; import { map, Observable } from 'rxjs'; import { LeavePresenceDocument, TouchPresenceDocument, + TypingDocument, + TypistsDocument, } from '../graphql/generated'; import { GraphqlOperations } from './graphql-operations'; @@ -17,6 +23,18 @@ export class GraphqlPresenceGateway extends PresenceGateway { .pipe(map((d) => d.touchPresence)); } + typing(issueId: string, threadId: string | null): Observable { + return this.gql + .mutate(TypingDocument, { issueId, threadId }) + .pipe(map(() => undefined)); + } + + typists(issueId: string): Observable { + return this.gql + .subscribe(TypistsDocument, { issueId }) + .pipe(map((d) => d.typing)); + } + leave(issueId: string): Observable { return this.gql .mutate(LeavePresenceDocument, { issueId }) diff --git a/libs/web/tracker/infrastructure/src/lib/graphql/fragments.graphql b/libs/web/tracker/infrastructure/src/lib/graphql/fragments.graphql index bde6786..606ac5b 100644 --- a/libs/web/tracker/infrastructure/src/lib/graphql/fragments.graphql +++ b/libs/web/tracker/infrastructure/src/lib/graphql/fragments.graphql @@ -185,6 +185,10 @@ fragment CommentFields on Comment { decidedBy { ...UserRefFields } + resolved + resolvedBy { + ...UserRefFields + } createdAt editedAt author { diff --git a/libs/web/tracker/infrastructure/src/lib/graphql/generated.ts b/libs/web/tracker/infrastructure/src/lib/graphql/generated.ts index 047c2ac..4d477de 100644 --- a/libs/web/tracker/infrastructure/src/lib/graphql/generated.ts +++ b/libs/web/tracker/infrastructure/src/lib/graphql/generated.ts @@ -720,6 +720,7 @@ export type IssueFieldsFragment = { replyTo: string | null; deleted: boolean; decision: boolean; + resolved: boolean; createdAt: string; editedAt: string | null; reactions: Array<{ @@ -727,6 +728,7 @@ export type IssueFieldsFragment = { users: Array<{ id: string; name: string }>; }>; decidedBy: { id: string; name: string } | null; + resolvedBy: { id: string; name: string } | null; author: { id: string; name: string }; }>; reporter: { id: string; name: string }; @@ -773,6 +775,7 @@ export type CommentFieldsFragment = { replyTo: string | null; deleted: boolean; decision: boolean; + resolved: boolean; createdAt: string; editedAt: string | null; reactions: Array<{ @@ -780,6 +783,7 @@ export type CommentFieldsFragment = { users: Array<{ id: string; name: string }>; }>; decidedBy: { id: string; name: string } | null; + resolvedBy: { id: string; name: string } | null; author: { id: string; name: string }; }; @@ -961,6 +965,7 @@ export type IssueQuery = { replyTo: string | null; deleted: boolean; decision: boolean; + resolved: boolean; createdAt: string; editedAt: string | null; reactions: Array<{ @@ -968,6 +973,7 @@ export type IssueQuery = { users: Array<{ id: string; name: string }>; }>; decidedBy: { id: string; name: string } | null; + resolvedBy: { id: string; name: string } | null; author: { id: string; name: string }; }>; reporter: { id: string; name: string }; @@ -1181,6 +1187,7 @@ export type IssueCommentsQuery = { replyTo: string | null; deleted: boolean; decision: boolean; + resolved: boolean; createdAt: string; editedAt: string | null; reactions: Array<{ @@ -1188,6 +1195,7 @@ export type IssueCommentsQuery = { users: Array<{ id: string; name: string }>; }>; decidedBy: { id: string; name: string } | null; + resolvedBy: { id: string; name: string } | null; author: { id: string; name: string }; }>; }; @@ -1589,6 +1597,16 @@ export type ReactToCommentMutation = { reactToComment: { id: string; key: string | null; version: number }; }; +export type ResolveThreadMutationVariables = Exact<{ + commentId: string; + resolved: boolean; + conclusion?: string | null | undefined; +}>; + +export type ResolveThreadMutation = { + resolveThread: { id: string; key: string | null; version: number }; +}; + export type MarkDecisionMutationVariables = Exact<{ commentId: string; on: boolean; @@ -1612,6 +1630,7 @@ export type SearchDecisionsQuery = { replyTo: string | null; deleted: boolean; decision: boolean; + resolved: boolean; createdAt: string; editedAt: string | null; reactions: Array<{ @@ -1619,6 +1638,7 @@ export type SearchDecisionsQuery = { users: Array<{ id: string; name: string }>; }>; decidedBy: { id: string; name: string } | null; + resolvedBy: { id: string; name: string } | null; author: { id: string; name: string }; }; issue: { id: string; key: string; title: string }; @@ -1758,6 +1778,25 @@ export type LeavePresenceMutationVariables = Exact<{ export type LeavePresenceMutation = { leavePresence: boolean }; +export type TypingMutationVariables = Exact<{ + issueId: string; + threadId?: string | null | undefined; +}>; + +export type TypingMutation = { typing: boolean }; + +export type TypistsSubscriptionVariables = Exact<{ + issueId: string; +}>; + +export type TypistsSubscription = { + typing: { + threadId: string | null; + at: string; + user: { id: string; name: string }; + }; +}; + export type MyProjectsQueryVariables = Exact<{ [key: string]: never }>; export type MyProjectsQuery = { @@ -2916,6 +2955,7 @@ export type CommentChangedSubscription = { replyTo: string | null; deleted: boolean; decision: boolean; + resolved: boolean; createdAt: string; editedAt: string | null; reactions: Array<{ @@ -2923,6 +2963,7 @@ export type CommentChangedSubscription = { users: Array<{ id: string; name: string }>; }>; decidedBy: { id: string; name: string } | null; + resolvedBy: { id: string; name: string } | null; author: { id: string; name: string }; } | null; }; @@ -3712,6 +3753,20 @@ export const CommentFieldsFragmentDoc = { ], }, }, + { kind: 'Field', name: { kind: 'Name', value: 'resolved' } }, + { + kind: 'Field', + name: { kind: 'Name', value: 'resolvedBy' }, + selectionSet: { + kind: 'SelectionSet', + selections: [ + { + kind: 'FragmentSpread', + name: { kind: 'Name', value: 'UserRefFields' }, + }, + ], + }, + }, { kind: 'Field', name: { kind: 'Name', value: 'createdAt' } }, { kind: 'Field', name: { kind: 'Name', value: 'editedAt' } }, { @@ -4056,6 +4111,20 @@ export const IssueFieldsFragmentDoc = { ], }, }, + { kind: 'Field', name: { kind: 'Name', value: 'resolved' } }, + { + kind: 'Field', + name: { kind: 'Name', value: 'resolvedBy' }, + selectionSet: { + kind: 'SelectionSet', + selections: [ + { + kind: 'FragmentSpread', + name: { kind: 'Name', value: 'UserRefFields' }, + }, + ], + }, + }, { kind: 'Field', name: { kind: 'Name', value: 'createdAt' } }, { kind: 'Field', name: { kind: 'Name', value: 'editedAt' } }, { @@ -6304,6 +6373,20 @@ export const IssueDocument = { ], }, }, + { kind: 'Field', name: { kind: 'Name', value: 'resolved' } }, + { + kind: 'Field', + name: { kind: 'Name', value: 'resolvedBy' }, + selectionSet: { + kind: 'SelectionSet', + selections: [ + { + kind: 'FragmentSpread', + name: { kind: 'Name', value: 'UserRefFields' }, + }, + ], + }, + }, { kind: 'Field', name: { kind: 'Name', value: 'createdAt' } }, { kind: 'Field', name: { kind: 'Name', value: 'editedAt' } }, { @@ -7684,6 +7767,20 @@ export const IssueCommentsDocument = { ], }, }, + { kind: 'Field', name: { kind: 'Name', value: 'resolved' } }, + { + kind: 'Field', + name: { kind: 'Name', value: 'resolvedBy' }, + selectionSet: { + kind: 'SelectionSet', + selections: [ + { + kind: 'FragmentSpread', + name: { kind: 'Name', value: 'UserRefFields' }, + }, + ], + }, + }, { kind: 'Field', name: { kind: 'Name', value: 'createdAt' } }, { kind: 'Field', name: { kind: 'Name', value: 'editedAt' } }, { @@ -10172,6 +10269,114 @@ export const ReactToCommentDocument = { ReactToCommentMutation, ReactToCommentMutationVariables >; +export const ResolveThreadDocument = { + kind: 'Document', + definitions: [ + { + kind: 'OperationDefinition', + operation: 'mutation', + name: { kind: 'Name', value: 'ResolveThread' }, + variableDefinitions: [ + { + kind: 'VariableDefinition', + variable: { + kind: 'Variable', + name: { kind: 'Name', value: 'commentId' }, + }, + type: { + kind: 'NonNullType', + type: { kind: 'NamedType', name: { kind: 'Name', value: 'ID' } }, + }, + }, + { + kind: 'VariableDefinition', + variable: { + kind: 'Variable', + name: { kind: 'Name', value: 'resolved' }, + }, + type: { + kind: 'NonNullType', + type: { + kind: 'NamedType', + name: { kind: 'Name', value: 'Boolean' }, + }, + }, + }, + { + kind: 'VariableDefinition', + variable: { + kind: 'Variable', + name: { kind: 'Name', value: 'conclusion' }, + }, + type: { kind: 'NamedType', name: { kind: 'Name', value: 'String' } }, + }, + ], + selectionSet: { + kind: 'SelectionSet', + selections: [ + { + kind: 'Field', + name: { kind: 'Name', value: 'resolveThread' }, + arguments: [ + { + kind: 'Argument', + name: { kind: 'Name', value: 'commentId' }, + value: { + kind: 'Variable', + name: { kind: 'Name', value: 'commentId' }, + }, + }, + { + kind: 'Argument', + name: { kind: 'Name', value: 'resolved' }, + value: { + kind: 'Variable', + name: { kind: 'Name', value: 'resolved' }, + }, + }, + { + kind: 'Argument', + name: { kind: 'Name', value: 'conclusion' }, + value: { + kind: 'Variable', + name: { kind: 'Name', value: 'conclusion' }, + }, + }, + ], + 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< + ResolveThreadMutation, + ResolveThreadMutationVariables +>; export const MarkDecisionDocument = { kind: 'Document', definitions: [ @@ -10414,6 +10619,20 @@ export const SearchDecisionsDocument = { ], }, }, + { kind: 'Field', name: { kind: 'Name', value: 'resolved' } }, + { + kind: 'Field', + name: { kind: 'Name', value: 'resolvedBy' }, + selectionSet: { + kind: 'SelectionSet', + selections: [ + { + kind: 'FragmentSpread', + name: { kind: 'Name', value: 'UserRefFields' }, + }, + ], + }, + }, { kind: 'Field', name: { kind: 'Name', value: 'createdAt' } }, { kind: 'Field', name: { kind: 'Name', value: 'editedAt' } }, { @@ -11196,6 +11415,141 @@ export const LeavePresenceDocument = { LeavePresenceMutation, LeavePresenceMutationVariables >; +export const TypingDocument = { + kind: 'Document', + definitions: [ + { + kind: 'OperationDefinition', + operation: 'mutation', + name: { kind: 'Name', value: 'Typing' }, + variableDefinitions: [ + { + kind: 'VariableDefinition', + variable: { + kind: 'Variable', + name: { kind: 'Name', value: 'issueId' }, + }, + type: { + kind: 'NonNullType', + type: { kind: 'NamedType', name: { kind: 'Name', value: 'ID' } }, + }, + }, + { + kind: 'VariableDefinition', + variable: { + kind: 'Variable', + name: { kind: 'Name', value: 'threadId' }, + }, + type: { kind: 'NamedType', name: { kind: 'Name', value: 'ID' } }, + }, + ], + selectionSet: { + kind: 'SelectionSet', + selections: [ + { + kind: 'Field', + name: { kind: 'Name', value: 'typing' }, + arguments: [ + { + kind: 'Argument', + name: { kind: 'Name', value: 'issueId' }, + value: { + kind: 'Variable', + name: { kind: 'Name', value: 'issueId' }, + }, + }, + { + kind: 'Argument', + name: { kind: 'Name', value: 'threadId' }, + value: { + kind: 'Variable', + name: { kind: 'Name', value: 'threadId' }, + }, + }, + ], + }, + ], + }, + }, + ], +} as unknown as DocumentNode; +export const TypistsDocument = { + kind: 'Document', + definitions: [ + { + kind: 'OperationDefinition', + operation: 'subscription', + name: { kind: 'Name', value: 'Typists' }, + 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: 'typing' }, + arguments: [ + { + kind: 'Argument', + name: { kind: 'Name', value: 'issueId' }, + value: { + kind: 'Variable', + name: { kind: 'Name', value: 'issueId' }, + }, + }, + ], + selectionSet: { + kind: 'SelectionSet', + selections: [ + { + kind: 'Field', + name: { kind: 'Name', value: 'user' }, + selectionSet: { + kind: 'SelectionSet', + selections: [ + { + kind: 'FragmentSpread', + name: { kind: 'Name', value: 'UserRefFields' }, + }, + ], + }, + }, + { kind: 'Field', name: { kind: 'Name', value: 'threadId' } }, + { kind: 'Field', name: { kind: 'Name', value: 'at' } }, + ], + }, + }, + ], + }, + }, + { + kind: 'FragmentDefinition', + name: { kind: 'Name', value: 'UserRefFields' }, + typeCondition: { + kind: 'NamedType', + name: { kind: 'Name', value: 'UserRef' }, + }, + selectionSet: { + kind: 'SelectionSet', + selections: [ + { kind: 'Field', name: { kind: 'Name', value: 'id' } }, + { kind: 'Field', name: { kind: 'Name', value: 'name' } }, + ], + }, + }, + ], +} as unknown as DocumentNode; export const MyProjectsDocument = { kind: 'Document', definitions: [ @@ -17206,6 +17560,20 @@ export const CommentChangedDocument = { ], }, }, + { kind: 'Field', name: { kind: 'Name', value: 'resolved' } }, + { + kind: 'Field', + name: { kind: 'Name', value: 'resolvedBy' }, + selectionSet: { + kind: 'SelectionSet', + selections: [ + { + kind: 'FragmentSpread', + name: { kind: 'Name', value: 'UserRefFields' }, + }, + ], + }, + }, { kind: 'Field', name: { kind: 'Name', value: 'createdAt' } }, { kind: 'Field', name: { kind: 'Name', value: 'editedAt' } }, { diff --git a/libs/web/tracker/infrastructure/src/lib/graphql/issues.graphql b/libs/web/tracker/infrastructure/src/lib/graphql/issues.graphql index b76c104..2d76132 100644 --- a/libs/web/tracker/infrastructure/src/lib/graphql/issues.graphql +++ b/libs/web/tracker/infrastructure/src/lib/graphql/issues.graphql @@ -293,6 +293,20 @@ mutation ReactToComment($commentId: ID!, $emoji: String!, $on: Boolean!) { } } +mutation ResolveThread( + $commentId: ID! + $resolved: Boolean! + $conclusion: String +) { + resolveThread( + commentId: $commentId + resolved: $resolved + conclusion: $conclusion + ) { + ...CommandResultFields + } +} + mutation MarkDecision($commentId: ID!, $on: Boolean!) { markDecision(commentId: $commentId, on: $on) { ...CommandResultFields diff --git a/libs/web/tracker/infrastructure/src/lib/graphql/presence.graphql b/libs/web/tracker/infrastructure/src/lib/graphql/presence.graphql index 1c6d8c4..0016073 100644 --- a/libs/web/tracker/infrastructure/src/lib/graphql/presence.graphql +++ b/libs/web/tracker/infrastructure/src/lib/graphql/presence.graphql @@ -11,3 +11,17 @@ mutation TouchPresence($issueId: ID!, $editing: Boolean!) { mutation LeavePresence($issueId: ID!) { leavePresence(issueId: $issueId) } + +mutation Typing($issueId: ID!, $threadId: ID) { + typing(issueId: $issueId, threadId: $threadId) +} + +subscription Typists($issueId: ID!) { + typing(issueId: $issueId) { + user { + ...UserRefFields + } + threadId + at + } +} 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 912510c..42805f8 100644 --- a/libs/web/tracker/presenters/src/lib/pages/issue.page.scss +++ b/libs/web/tracker/presenters/src/lib/pages/issue.page.scss @@ -641,24 +641,104 @@ a.ts-key { // A text link, past @pi/ui's global button look: as a 40 px pill it sat on // the line under the comment. -.ts-reply__open { - justify-self: start; +.ts-thread__actions { + display: flex; + flex-wrap: wrap; + gap: 0.25rem 1rem; + margin-top: 0.375rem; +} + +/* Small text buttons, not @pi/ui's sized ones. */ +.ts-thread__action { + flex: none; height: auto !important; min-width: 0 !important; - margin-top: 0.375rem; padding: 0.125rem 0 !important; border: 0; background: none !important; color: var(--ts-brand); + font: inherit; font-size: 0.8125rem; font-weight: 600; cursor: pointer; - &:hover { + &:hover, + &:focus-visible { text-decoration: underline; } } +/* A settled conversation, folded to one line. */ +.ts-thread-done { + display: flex; + align-items: center; + gap: 0.75rem; + margin: 0.5rem 0; + padding: 0.625rem 0.875rem; + border: 1px solid var(--ts-line); + border-radius: 0.625rem; + background: var(--ts-sunken); +} + +.ts-thread-done__check { + flex: none; + display: inline-grid; + place-items: center; + width: 1.5rem; + height: 1.5rem; + border-radius: 50%; + background: color-mix(in srgb, var(--ts-c-green) 18%, transparent); + color: var(--ts-c-green); + font-weight: 700; +} + +.ts-thread-done__text { + display: grid; + flex: 1; + min-width: 0; + font-size: 0.875rem; + + > span { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } +} + +.ts-thread-done__topic { + font-weight: 600; +} + +.ts-open-threads { + margin-left: auto; + padding: 0.0625rem 0.5rem; + border-radius: 999px; + background: color-mix(in srgb, var(--ts-c-amber) 18%, transparent); + color: var(--ts-ink); + font-size: 0.6875rem; + letter-spacing: 0.04em; +} + +.ts-typing { + margin: 0; + color: var(--ts-muted); + font-size: 0.8125rem; + font-style: italic; +} + +.ts-resolve__label { + display: grid; + gap: 0.375rem; + color: var(--ts-muted); + font-size: 0.8125rem; +} + +.ts-resolve__text { + font: inherit; + color: var(--ts-ink); + resize: vertical; +} + .ts-reply__actions { display: flex; gap: 0.75rem; 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 a7af7ca..4332e7d 100644 --- a/libs/web/tracker/presenters/src/lib/pages/issue.page.ts +++ b/libs/web/tracker/presenters/src/lib/pages/issue.page.ts @@ -53,6 +53,7 @@ import { isTyping, POINT_SCALE, ReleaseStatus, + StatusCategory, } from '@task-stream/web/tracker/domain'; import { clock, @@ -654,6 +655,16 @@ const MAX_FILE_BYTES = 25 * 1024 * 1024;

Comentarios {{ commentCount() }} + @if (openThreads()) { + {{ openThreads() }} + {{ + openThreads() === 1 + ? 'conversación abierta' + : 'conversaciones abiertas' + }} + }

@for (thread of threads(); track thread.comment.id) { - -
- @for (reply of thread.replies; track reply.id) { - - } - @if (replyingTo() === thread.comment.id) { -
+
+ - -
- - -
- - } @else if (project.canReport() && !thread.comment.deleted) { +
+ {{ + thread.comment.deleted + ? 'Conversación' + : excerpt(thread.comment.body) + }} + Resuelta{{ + thread.comment.resolvedBy + ? ' por ' + thread.comment.resolvedBy.name + : '' + }}{{ + conclusionOf(thread) + ? ': ' + excerpt(conclusionOf(thread) ?? '') + : '' + }} +
- } -
+
+ } @else { + +
+ @for (reply of thread.replies; track reply.id) { + + } + @if (typistsIn(thread.comment.id); as typing) { +

{{ typing }}

+ } + @if (resolving() === thread.comment.id) { +
+ +
+ + +
+
+ } @else if (replyingTo() === thread.comment.id) { +
+ +
+ + +
+ + } @else { +
+ @if (project.canReport() && !thread.comment.deleted) { + + } + @if (project.canContribute()) { + @if (thread.comment.resolved) { + + + } @else if ( + thread.replies.length && !thread.comment.deleted + ) { + + } + } @else if (thread.comment.resolved) { + + } +
+ } +
+ } + } + @if (typistsIn(null); as typing) { +

{{ typing }}

} @if (project.canReport()) {
(null); protected readonly replyBody = signal(''); + /** The conversation being settled, and its conclusion. */ + protected readonly resolving = signal(null); + protected readonly conclusion = signal(''); + /** Settled conversations unfolded to read. */ + protected readonly expanded = signal>(new Set()); /** Top-level comments, each with its replies (oldest first). */ protected readonly threads = computed(() => { const comments = this.facade.comments(); @@ -1414,6 +1550,14 @@ export class IssuePage { if (id && this.facade.comments().some((c) => c.id === id)) untracked(() => setTimeout(() => this.goToComment(id))); }); + // Writing an answer or a comment: the others see it as I type. + effect(() => { + if (this.replyBody().trim()) + untracked(() => this.presence.typed(this.replyingTo())); + }); + effect(() => { + if (this.commentBody().trim()) untracked(() => this.presence.typed(null)); + }); effect(() => { const editing = this.editingInDrawer() || !!this.commentBody().trim(); untracked(() => this.presence.setEditing(editing)); @@ -1473,6 +1617,17 @@ export class IssuePage { } protected move(to: string): void { + const open = this.openThreads(); + if ( + open && + this.project.workflow().status(to)?.category === StatusCategory.DONE && + !confirm( + open === 1 + ? 'Queda una conversación sin resolver. ¿Terminar el ticket igualmente?' + : `Quedan ${open} conversaciones sin resolver. ¿Terminar el ticket igualmente?`, + ) + ) + return; if (this.project.workflow().requiresReason(to)) { this.pendingStatus = to; this.reason().reset({ text: '' }); @@ -1565,6 +1720,68 @@ export class IssuePage { this.replyingTo.set(comment.id); } + protected startResolve(comment: Comment): void { + this.conclusion.set(''); + this.replyingTo.set(null); + this.resolving.set(comment.id); + } + + protected resolve(comment: Comment): void { + this.facade.resolveThread(comment.id, true, this.conclusion()); + this.resolving.set(null); + this.conclusion.set(''); + // It folds once settled. + this.expanded.update((ids) => { + const next = new Set(ids); + next.delete(comment.id); + return next; + }); + } + + protected toggleThread(id: string): void { + this.expanded.update((ids) => { + const next = new Set(ids); + if (!next.delete(id)) next.add(id); + return next; + }); + } + + /** Conversations with answers still to settle. */ + protected readonly openThreads = computed( + () => + this.threads().filter( + (t) => t.replies.length && !t.comment.resolved && !t.comment.deleted, + ).length, + ); + + /** What a settled conversation concluded: its last reply marked as a decision. */ + protected conclusionOf(thread: { replies: Comment[] }): string | null { + const decided = thread.replies.filter((r) => r.decision && !r.deleted); + return decided[decided.length - 1]?.body ?? null; + } + + /** The first words of a comment, on one line. */ + protected excerpt(body: string): string { + const text = body + .replace(/!?\[([^\]]*)\]\([^)]*\)/g, '$1') + .replace(/[*_`~#>]+/g, '') + .replace(/\s+/g, ' ') + .trim(); + return text.length > 80 ? `${text.slice(0, 79)}…` : text; + } + + /** «Ana está escribiendo…» in a conversation (null: a new comment). */ + protected typistsIn(threadId: string | null): string | null { + const names = this.presence + .typists() + .filter((t) => t.threadId === threadId) + .map((t) => t.user.name); + if (!names.length) return null; + return names.length === 1 + ? `${names[0]} está escribiendo…` + : `${names.slice(0, -1).join(', ')} y ${names[names.length - 1]} están escribiendo…`; + } + protected reply(comment: Comment): void { const body = this.replyBody().trim(); if (!body) return; diff --git a/libs/web/tracker/presenters/src/lib/ui/rich-editor.ts b/libs/web/tracker/presenters/src/lib/ui/rich-editor.ts index 91f86c8..717924a 100644 --- a/libs/web/tracker/presenters/src/lib/ui/rich-editor.ts +++ b/libs/web/tracker/presenters/src/lib/ui/rich-editor.ts @@ -423,7 +423,9 @@ export class RichEditorComponent implements FormValueControl { /** Turns pasting screenshots on. */ readonly upload = input(null); readonly loader = input(null); - /** Ctrl/Cmd+Enter. */ + /** Plain Enter sends too (Shift+Enter breaks the line), like a chat. */ + readonly enterSends = input(false); + /** Ctrl/Cmd+Enter (and Enter with `enterSends`). */ readonly submitted = output(); readonly touch = output(); @@ -440,6 +442,7 @@ export class RichEditorComponent implements FormValueControl { }); protected readonly hint = computed(() => [ + this.enterSends() ? 'Enter envía, Shift+Enter cambia de línea' : '', this.members().length ? 'Escribe @ para mencionar a alguien' : '', this.upload() ? 'pega capturas con Ctrl+V' : '', ] @@ -539,6 +542,7 @@ export class RichEditorComponent implements FormValueControl { placeholder: () => this.placeholder(), loader: this.loader(), onSubmit: () => this.submitted.emit(), + enterSends: () => this.enterSends(), suggestion: { items: ({ query }) => this.members() diff --git a/libs/web/tracker/presenters/src/lib/ui/rich-text.ts b/libs/web/tracker/presenters/src/lib/ui/rich-text.ts index 0782f2d..4d8b075 100644 --- a/libs/web/tracker/presenters/src/lib/ui/rich-text.ts +++ b/libs/web/tracker/presenters/src/lib/ui/rich-text.ts @@ -106,6 +106,11 @@ export interface RichTextOptions { suggestion?: Omit, 'editor'> | null; /** Ctrl/Cmd+Enter. */ onSubmit?: () => void; + /** + * Plain Enter submits too (Shift+Enter breaks the line), like a chat; + * not inside a list or code, where Enter means a new item or line. + */ + enterSends?: () => boolean; } /** @@ -152,6 +157,17 @@ export function richTextExtensions( options.onSubmit?.(); return !!options.onSubmit; }, + Enter: ({ editor }) => { + if (!options.onSubmit || !options.enterSends?.()) return false; + if ( + editor.isActive('codeBlock') || + editor.isActive('listItem') || + editor.isActive('taskItem') + ) + return false; + options.onSubmit(); + return true; + }, }), }), Markdown.configure({ markedOptions: { gfm: true, breaks: true } }),