diff --git a/apps/api/openapi.json b/apps/api/openapi.json index eb1453f..16063be 100644 --- a/apps/api/openapi.json +++ b/apps/api/openapi.json @@ -4406,7 +4406,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/CommentBodyDto" + "$ref": "#/components/schemas/AddCommentDto" } } } @@ -4491,7 +4491,7 @@ "pireon": [] } ], - "summary": "Comment on the ticket", + "summary": "Comment on the ticket, or answer a comment (replyTo)", "tags": [ "Issues" ] @@ -11423,7 +11423,18 @@ "$ref": "#/components/schemas/UserRefDto" }, "body": { - "type": "string" + "type": "string", + "description": "Empty on a deleted comment kept for its replies" + }, + "replyTo": { + "type": "string", + "nullable": true, + "format": "uuid", + "description": "The top-level comment it answers; null for a top-level one" + }, + "deleted": { + "type": "boolean", + "description": "Deleted, but kept (without its text) while it has replies" }, "createdAt": { "type": "string", @@ -11443,6 +11454,8 @@ "issueId", "author", "body", + "replyTo", + "deleted", "createdAt", "editedAt", "version" @@ -11466,13 +11479,18 @@ "total" ] }, - "CommentBodyDto": { + "AddCommentDto": { "type": "object", "properties": { "body": { "type": "string", "example": "Reproducido en Safari 17.", "maxLength": 20000 + }, + "replyTo": { + "type": "string", + "format": "uuid", + "description": "Comment it answers (one level: answering a reply joins its thread)" } }, "required": [ @@ -11497,6 +11515,19 @@ "file" ] }, + "CommentBodyDto": { + "type": "object", + "properties": { + "body": { + "type": "string", + "example": "Reproducido en Safari 17.", + "maxLength": 20000 + } + }, + "required": [ + "body" + ] + }, "ReleaseStatus": { "type": "string", "enum": [ @@ -11929,6 +11960,7 @@ "QA_FAILED", "COMMENTED", "MENTIONED", + "REPLIED", "WATCHED", "ADDED_TO_PROJECT", "REMINDER" diff --git a/apps/api/schema.gql b/apps/api/schema.gql index 22584ec..2f05504 100644 --- a/apps/api/schema.gql +++ b/apps/api/schema.gql @@ -196,11 +196,19 @@ type CommandResult { type Comment { author: UserRef! + + """Empty on a deleted comment kept for its replies""" body: String! createdAt: String! + + """Deleted, but kept (without its text) while it has replies""" + deleted: Boolean! editedAt: String id: ID! issueId: ID! + + """The top-level comment it answers; null for a top-level one""" + replyTo: ID version: Int! } @@ -602,7 +610,11 @@ type Me { type Mutation { """Add a step to a ticket's checklist""" addChecklistItem(input: AddChecklistItemInput!): CommandResult! - addComment(body: String!, issueId: ID!): CommandResult! + + """ + Comment on a ticket, or answer a comment (replyTo; one level of replies) + """ + addComment(body: String!, issueId: ID!, replyTo: ID): CommandResult! """ Add a repository with its own secret (the first turns the integration on) @@ -776,6 +788,7 @@ enum NotificationKind { MENTIONED QA_FAILED REMINDER + REPLIED STATUS_CHANGED WATCHED } diff --git a/apps/mcp/src/app/api/taskstream.schema.ts b/apps/mcp/src/app/api/taskstream.schema.ts index a360b02..c20aa96 100644 --- a/apps/mcp/src/app/api/taskstream.schema.ts +++ b/apps/mcp/src/app/api/taskstream.schema.ts @@ -635,7 +635,7 @@ export interface paths { /** Comments of the ticket (oldest first) */ get: operations["IssuesController_listComments"]; put?: never; - /** Comment on the ticket */ + /** Comment on the ticket, or answer a comment (replyTo) */ post: operations["IssuesController_addComment"]; delete?: never; options?: never; @@ -2087,7 +2087,15 @@ export interface components { /** Format: uuid */ issueId: string; author: components["schemas"]["UserRefDto"]; + /** @description Empty on a deleted comment kept for its replies */ body: string; + /** + * Format: uuid + * @description The top-level comment it answers; null for a top-level one + */ + replyTo: string | null; + /** @description Deleted, but kept (without its text) while it has replies */ + deleted: boolean; /** Format: date-time */ createdAt: string; /** Format: date-time */ @@ -2098,9 +2106,14 @@ export interface components { items: components["schemas"]["CommentDto"][]; total: number; }; - CommentBodyDto: { + AddCommentDto: { /** @example Reproducido en Safari 17. */ body: string; + /** + * Format: uuid + * @description Comment it answers (one level: answering a reply joins its thread) + */ + replyTo?: string; }; AttachFileDto: { /** @@ -2114,6 +2127,10 @@ export interface components { */ commentId?: string; }; + CommentBodyDto: { + /** @example Reproducido en Safari 17. */ + body: string; + }; /** @enum {string} */ ReleaseStatus: "PLANNED" | "RELEASED" | "ARCHIVED"; ReleaseDto: { @@ -2250,7 +2267,7 @@ export interface components { goodFirst: components["schemas"]["ShowcaseIssueDto"][]; }; /** @enum {string} */ - NotificationKind: "ASSIGNED" | "STATUS_CHANGED" | "QA_FAILED" | "COMMENTED" | "MENTIONED" | "WATCHED" | "ADDED_TO_PROJECT" | "REMINDER"; + NotificationKind: "ASSIGNED" | "STATUS_CHANGED" | "QA_FAILED" | "COMMENTED" | "MENTIONED" | "REPLIED" | "WATCHED" | "ADDED_TO_PROJECT" | "REMINDER"; NotificationDto: { id: string; kind: components["schemas"]["NotificationKind"]; @@ -5497,7 +5514,7 @@ export interface operations { }; requestBody: { content: { - "application/json": components["schemas"]["CommentBodyDto"]; + "application/json": components["schemas"]["AddCommentDto"]; }; }; responses: { diff --git a/apps/mcp/src/app/tools/issues.resolver.ts b/apps/mcp/src/app/tools/issues.resolver.ts index 78235a6..0b5778a 100644 --- a/apps/mcp/src/app/tools/issues.resolver.ts +++ b/apps/mcp/src/app/tools/issues.resolver.ts @@ -224,9 +224,10 @@ export class IssuesResolver { ) ).items.map((c) => ({ id: c.id, - author: c.author.name, + replyTo: c.replyTo, + author: c.deleted ? null : c.author.name, at: c.createdAt, - body: c.body, + body: c.deleted ? '(deleted; kept for its replies)' : c.body, })) : undefined; const history = params.withHistory @@ -411,19 +412,27 @@ export class IssuesResolver { @Tool({ name: 'add_comment', - description: 'Comment on a ticket (decisions, explanations, steps).', + description: + 'Comment on a ticket (decisions, explanations, steps), or answer a comment with replyTo (one level: answering a reply joins its thread).', paramsSchema: z.object({ issue: issueRef, body: z.string().min(1).max(20000), + replyTo: z + .string() + .optional() + .describe('Id of the comment it answers (see get_issue)'), }), annotations: WRITE, }) - comment({ issue, body }: { issue: string; body: string }, ctx: McpContext) { + comment( + { issue, body, replyTo }: { issue: string; body: string; replyTo?: string }, + ctx: McpContext, + ) { return toolResult(() => this.api.data( this.api.as(ctx).POST('/api/issues/{issue}/comments', { params: { path: { issue } }, - body: { body }, + body: { body, replyTo }, }), ), ); diff --git a/docs/01-vision-general.md b/docs/01-vision-general.md index 62b2548..ea27040 100644 --- a/docs/01-vision-general.md +++ b/docs/01-vision-general.md @@ -14,7 +14,7 @@ | 🚀 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 `@menciones`; el autor edita, el admin modera | +| 💬 Comentarios | Decisiones y explicaciones dentro del ticket, con respuestas 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 | | 🚦 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 | @@ -28,7 +28,7 @@ | 🌐 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; quien comenta, los sigue solo | -| 🔔 Avisos | Campana con lo que te concierne (asignaciones, cambios de estado, QA fallido, comentarios), en vivo y opcionalmente por correo | +| 🔔 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) | diff --git a/docs/03-api.md b/docs/03-api.md index 5fa77bb..b24a053 100644 --- a/docs/03-api.md +++ b/docs/03-api.md @@ -145,7 +145,11 @@ stateDiagram-v2 Las notas de versión (`notes`, Markdown) se escriben también después de publicar (no una vez archivada); `notesDraft` / `GET /api/releases/{id}/notes/draft` propone un borrador con los tickets hechos agrupados por tipo (los de QA quedan fuera). -- **Comentarios**: solo el autor edita; borra el autor o un admin del proyecto. +- **Comentarios**: solo el autor edita; borra el autor o un admin del proyecto. Se puede responder a un comentario + (`replyTo`), con un solo nivel de respuestas: responder a una respuesta la suma al mismo hilo, y la respuesta tiene + que ser del mismo ticket. Quien escribió el comentario recibe un aviso (`REPLIED`). Un comentario borrado que tiene + respuestas se queda, sin texto (`deleted: true`), para que el hilo se siga entendiendo, y desaparece con su última + respuesta; no cuenta en `commentCount`. - **Cambios de UI**: `figmaUrl` debe ser un enlace `https://…figma.com/…`. - **Adjuntos**: los suben los miembros (no los lectores), hasta `ATTACHMENTS_MAX_MB` (25 MB) por archivo y 50 por ticket; pueden ir en el ticket o bajo uno de sus comentarios (`commentId`). Quita uno quien lo subió o un admin. @@ -333,15 +337,15 @@ 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) | -| `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) | +| `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. diff --git a/docs/09-mcp.md b/docs/09-mcp.md index 65ad4b3..f813bbb 100644 --- a/docs/09-mcp.md +++ b/docs/09-mcp.md @@ -57,7 +57,7 @@ flowchart LR | | `change_issue_status` | Mover de estado (con motivo cuando el estado lo pide) | | | `assign_issue` | Asignar o desasignar | | | `set_issue_release` | Meter en una release o sacar | -| | `add_comment` / `edit_comment` / `delete_comment` | Comentar, corregir o borrar un comentario | +| | `add_comment` / `edit_comment` / `delete_comment` | Comentar (o responder a un comentario), corregir o borrar | | | `link_issues` / `unlink_issues` | Relacionar tickets (`DEPENDS_ON`, `BLOCKS`, `TESTS`…) o quitar la relación | | | `watch_issue` | Seguir un ticket o dejar de seguirlo | | | `add_checklist_item` / `update_checklist_item` / `remove_checklist_item` | La lista de comprobación: añadir, marcar o reescribir, quitar | diff --git a/docs/TaskStream-Documentacion.docx b/docs/TaskStream-Documentacion.docx index 0b5b9c7..0ce59a7 100644 Binary files a/docs/TaskStream-Documentacion.docx and b/docs/TaskStream-Documentacion.docx differ diff --git a/docs/TaskStream-Documentacion.pdf b/docs/TaskStream-Documentacion.pdf index 37cf2fe..ccf93ee 100644 Binary files a/docs/TaskStream-Documentacion.pdf and b/docs/TaskStream-Documentacion.pdf differ diff --git a/libs/api/tracker/application/src/lib/commands/add-comment.command-handler.ts b/libs/api/tracker/application/src/lib/commands/add-comment.command-handler.ts index 541e975..a04fb39 100644 --- a/libs/api/tracker/application/src/lib/commands/add-comment.command-handler.ts +++ b/libs/api/tracker/application/src/lib/commands/add-comment.command-handler.ts @@ -1,4 +1,5 @@ import { CommandHandler, EventPublisher, ICommandHandler } from '@nestjs/cqrs'; +import { BusinessRuleError } from '@task-stream/api/shared/domain'; import { CommentFactory } from '@task-stream/api/tracker/domain'; import { AddCommentCommand } from './add-comment.command'; import { CommandResult, ack } from './command-result'; @@ -24,10 +25,24 @@ export class AddCommentCommandHandler projectId: issue.projectId, authorId: command.actorId, body: command.body, + replyTo: command.replyTo + ? await this.threadOf(command.replyTo, issue.id) + : null, }); this.eventPublisher.mergeObjectContext(comment); await comment.commitAndWait(); return ack(comment); } + + /** + * The top-level comment a reply hangs from: one level only, so answering + * a reply joins its thread. + */ + private async threadOf(commentId: string, issueId: string): Promise { + const parent = await this.access.comment(commentId); + if (parent.issueId !== issueId) + throw new BusinessRuleError('A reply must be on the same ticket'); + return parent.replyTo ?? parent.id; + } } diff --git a/libs/api/tracker/application/src/lib/commands/add-comment.command.ts b/libs/api/tracker/application/src/lib/commands/add-comment.command.ts index 6404fbd..9c776e3 100644 --- a/libs/api/tracker/application/src/lib/commands/add-comment.command.ts +++ b/libs/api/tracker/application/src/lib/commands/add-comment.command.ts @@ -6,6 +6,8 @@ export class AddCommentCommand extends Command { public readonly actorId: string, public readonly issueId: string, public readonly body: string, + /** Comment it answers; a reply to a reply joins the same thread. */ + public readonly replyTo: string | null = null, ) { super(); } 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 0827550..2b16630 100644 --- a/libs/api/tracker/application/src/lib/commands/commands.spec.ts +++ b/libs/api/tracker/application/src/lib/commands/commands.spec.ts @@ -832,6 +832,38 @@ describe('tracker commands (event sourced)', () => { expect(rebuilt.deleted).toBe(true); }); + it('answers comments one level deep, on the same ticket', async () => { + const { run, rehydrator } = await setup(); + const projectId = await projectWithTeam(run); + const spike = await run( + new CreateIssueCommand('dev', projectId, IssueType.TASK, 'Spike', {}), + ); + const other = await run( + new CreateIssueCommand('dev', projectId, IssueType.TASK, 'Other', {}), + ); + const root = await run(new AddCommentCommand('dev', spike.id, 'Redis?')); + const reply = await run( + new AddCommentCommand('qa', spike.id, 'Sí', root.id), + ); + // Answering a reply joins the same thread. + const again = await run( + new AddCommentCommand('dev', spike.id, 'Vale', reply.id), + ); + const replyTo = async (id: string) => + (await rehydrator.rehydrate(id, Comment)).replyTo; + expect(await replyTo(root.id)).toBeNull(); + expect(await replyTo(reply.id)).toBe(root.id); + expect(await replyTo(again.id)).toBe(root.id); + + await expect( + run(new AddCommentCommand('dev', other.id, 'Aquí no', root.id)), + ).rejects.toThrow(BusinessRuleError); + await run(new DeleteCommentCommand('qa', reply.id)); + await expect( + run(new AddCommentCommand('dev', spike.id, 'Tarde', reply.id)), + ).rejects.toThrow(NotFoundError); + }); + it('detects concurrent modifications through stream positions', async () => { const { run, rehydrator } = await setup(); const projectId = await projectWithTeam(run); diff --git a/libs/api/tracker/application/src/lib/comments.service.ts b/libs/api/tracker/application/src/lib/comments.service.ts index 6b9354b..f155212 100644 --- a/libs/api/tracker/application/src/lib/comments.service.ts +++ b/libs/api/tracker/application/src/lib/comments.service.ts @@ -15,9 +15,14 @@ export class CommentsService { private readonly queryBus: QueryBus, ) {} - add(actorId: string, issueId: string, body: string): Promise { + add( + actorId: string, + issueId: string, + body: string, + replyTo: string | null = null, + ): Promise { return this.commandBus.execute( - new AddCommentCommand(actorId, issueId, body), + new AddCommentCommand(actorId, issueId, body, replyTo), ); } diff --git a/libs/api/tracker/application/src/lib/event-handlers/activity-summary.ts b/libs/api/tracker/application/src/lib/event-handlers/activity-summary.ts index e4e8921..53456e6 100644 --- a/libs/api/tracker/application/src/lib/event-handlers/activity-summary.ts +++ b/libs/api/tracker/application/src/lib/event-handlers/activity-summary.ts @@ -200,7 +200,9 @@ export function describeEvent( return `${actor} quitó «${event.text}» de la lista de ${issueKey}`; if (event instanceof CommentAddedEvent) - return `${actor} comentó en ${issueKey}`; + return event.comment.replyTo + ? `${actor} respondió a un comentario en ${issueKey}` + : `${actor} comentó en ${issueKey}`; if (event instanceof CommentEditedEvent) return `${actor} editó un comentario en ${issueKey}`; if (event instanceof CommentDeletedEvent) 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 e9380b1..f3b7681 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 @@ -27,6 +27,8 @@ export class CommentAddedEventHandler projectId: comment.projectId, author: users.of(comment.authorId), body: comment.body, + replyTo: comment.replyTo ?? null, + deleted: false, createdAt: comment.createdAt, editedAt: null, version: versionOf(event), diff --git a/libs/api/tracker/application/src/lib/event-handlers/comment-deleted.event-handler.ts b/libs/api/tracker/application/src/lib/event-handlers/comment-deleted.event-handler.ts index d8400c3..9150512 100644 --- a/libs/api/tracker/application/src/lib/event-handlers/comment-deleted.event-handler.ts +++ b/libs/api/tracker/application/src/lib/event-handlers/comment-deleted.event-handler.ts @@ -3,7 +3,7 @@ import { CommentDeletedEvent } from '@task-stream/api/tracker/domain'; import { FindCommentsRepository } from '../ports/find-comments.repository'; import { UpsertMaterializedCommentRepository } from '../ports/upsert-materialized-comment.repository'; import { UpsertMaterializedIssueRepository } from '../ports/upsert-materialized-issue.repository'; -import { ReadModelNotifier } from './read-model-notifier'; +import { ReadModelNotifier, versionOf } from './read-model-notifier'; @EventsHandler(CommentDeletedEvent) export class CommentDeletedEventHandler @@ -17,12 +17,35 @@ export class CommentDeletedEventHandler ) {} async handle(event: CommentDeletedEvent) { - await this.upsertMaterializedCommentRepository.remove(event.aggregateId); + const comment = await this.findComments.findById(event.aggregateId); + await this.vanishOrKeep(event.aggregateId, event); + // The last reply under a deleted comment takes it along. + const parent = comment?.replyTo + ? await this.findComments.findById(comment.replyTo) + : null; + if (parent?.deleted) await this.vanishOrKeep(parent.id, event); await this.upsertMaterializedIssueRepository.setCommentCount( event.issueId, await this.findComments.countByIssue(event.issueId), ); - await this.notifier.comment(event.aggregateId, event.issueId, event); await this.notifier.issue(event.issueId, event); } + + /** + * A comment with replies stays, without its text, so the conversation + * under it still reads; one without them goes. + */ + private async vanishOrKeep(id: string, event: CommentDeletedEvent) { + if (await this.findComments.countReplies(id)) { + await this.upsertMaterializedCommentRepository.upsert({ + id, + version: versionOf(event), + body: '', + deleted: true, + }); + } else { + await this.upsertMaterializedCommentRepository.remove(id); + } + await this.notifier.comment(id, event.issueId, event); + } } diff --git a/libs/api/tracker/application/src/lib/notifications/notifications.event-handler.ts b/libs/api/tracker/application/src/lib/notifications/notifications.event-handler.ts index 6afb56b..697e6fc 100644 --- a/libs/api/tracker/application/src/lib/notifications/notifications.event-handler.ts +++ b/libs/api/tracker/application/src/lib/notifications/notifications.event-handler.ts @@ -11,6 +11,7 @@ import { TrackerEvent, withoutMentionMarkup, } from '@task-stream/api/tracker/domain'; +import { FindCommentsRepository } from '../ports/find-comments.repository'; import { FindIssuesRepository } from '../ports/find-issues.repository'; import { FindProjectsRepository } from '../ports/find-projects.repository'; import { UserDirectory } from '../ports/user-directory'; @@ -43,6 +44,7 @@ export class NotificationsEventHandler implements IEventHandler { private readonly findIssues: FindIssuesRepository, private readonly findProjects: FindProjectsRepository, private readonly users: UserDirectory, + private readonly findComments: FindCommentsRepository, ) {} async handle(event: TrackerEvent): Promise { @@ -154,6 +156,16 @@ export class NotificationsEventHandler implements IEventHandler { summary: `${actor} te mencionó en ${issue.key}: ${said}`, }); } + const answered = event.comment.replyTo + ? await this.findComments.findById(event.comment.replyTo) + : null; + if (answered && !answered.deleted) { + drafts.push({ + userId: answered.author.id, + kind: NotificationKind.REPLIED, + summary: `${actor} respondió a tu comentario en ${issue.key}: ${said}`, + }); + } for (const userId of [assigneeId, reporterId]) { if (!userId) continue; drafts.push({ diff --git a/libs/api/tracker/application/src/lib/notifications/notifications.spec.ts b/libs/api/tracker/application/src/lib/notifications/notifications.spec.ts index e2cffba..33b9dcd 100644 --- a/libs/api/tracker/application/src/lib/notifications/notifications.spec.ts +++ b/libs/api/tracker/application/src/lib/notifications/notifications.spec.ts @@ -12,6 +12,7 @@ import { } from '@task-stream/api/tracker/domain'; import { FakeUserDirectory } from '../../testing/in-memory'; import { ChangeFeed } from '../ports/change-feed'; +import { FindCommentsRepository } from '../ports/find-comments.repository'; import { FindIssuesRepository } from '../ports/find-issues.repository'; import { FindProjectsRepository } from '../ports/find-projects.repository'; import { NotificationMailer } from '../ports/notification-mailer'; @@ -100,6 +101,13 @@ function setup(mailEnabled = false) { }), } as unknown as FindProjectsRepository, users, + { + // A comment of the PM's, that others answer. + findById: async (id: string) => + id === 'c-pm' + ? { id, author: { id: 'pm', name: 'PM' }, deleted: false } + : null, + } as unknown as FindCommentsRepository, ); return { repo, published, mailed, handler }; } @@ -163,6 +171,7 @@ describe('notifications', () => { projectId: 'p-1', authorId: 'bob', body: 'Ya está arreglado', + replyTo: null, deleted: false, createdAt: '2026-10-08T10:00:00.000Z', editedAt: null, @@ -185,6 +194,7 @@ describe('notifications', () => { projectId: 'p-1', authorId: 'bob', body: '@[PM](pm) mira esto, y @[Fuera](stranger) también', + replyTo: null, deleted: false, createdAt: '2026-10-08T10:00:00.000Z', editedAt: null, @@ -200,6 +210,32 @@ describe('notifications', () => { expect(repo.all[0].summary).toContain('«@PM mira esto'); }); + it('tells whoever wrote a comment that someone answered it', async () => { + const { handler, repo } = setup(); + await handler.handle( + at( + new CommentAddedEvent('bob', { + id: 'c-3', + issueId: 'i-1', + projectId: 'p-1', + authorId: 'bob', + body: 'Lo miro', + replyTo: 'c-pm', + deleted: false, + createdAt: '2026-10-08T10:00:00.000Z', + editedAt: null, + }), + 1, + ), + ); + + expect(repo.all.map((n) => [n.userId, n.kind])).toEqual([ + ['pm', NotificationKind.REPLIED], + ['qa', NotificationKind.COMMENTED], + ]); + expect(repo.all[0].summary).toContain('respondió a tu comentario'); + }); + it('copies by e-mail only to whoever asked for it, when mail is configured', async () => { const { handler, repo, mailed } = setup(true); repo.emailOn.add('bob'); diff --git a/libs/api/tracker/application/src/lib/ports/find-comments.repository.ts b/libs/api/tracker/application/src/lib/ports/find-comments.repository.ts index b2f66a0..1d4928f 100644 --- a/libs/api/tracker/application/src/lib/ports/find-comments.repository.ts +++ b/libs/api/tracker/application/src/lib/ports/find-comments.repository.ts @@ -8,5 +8,7 @@ export abstract class FindCommentsRepository { limit: number, offset: number, ): Promise>; + /** Comments still standing (deleted ones kept for their replies do not count). */ abstract countByIssue(issueId: string): Promise; + abstract countReplies(commentId: string): Promise; } diff --git a/libs/api/tracker/application/src/lib/sagas/commenters-follow.saga.spec.ts b/libs/api/tracker/application/src/lib/sagas/commenters-follow.saga.spec.ts index 31c0fba..97886d7 100644 --- a/libs/api/tracker/application/src/lib/sagas/commenters-follow.saga.spec.ts +++ b/libs/api/tracker/application/src/lib/sagas/commenters-follow.saga.spec.ts @@ -11,6 +11,7 @@ describe('CommentersFollowSaga', () => { projectId: 'p-1', authorId: 'ana', body: 'Lo miro', + replyTo: null, deleted: false, createdAt: '2026-10-08T10:00:00.000Z', editedAt: null, diff --git a/libs/api/tracker/domain/src/lib/comment/comment.ts b/libs/api/tracker/domain/src/lib/comment/comment.ts index 6f997e8..eace8cf 100644 --- a/libs/api/tracker/domain/src/lib/comment/comment.ts +++ b/libs/api/tracker/domain/src/lib/comment/comment.ts @@ -15,6 +15,12 @@ export interface CommentProps { projectId: string; authorId: string; body: string; + /** + * The comment this one answers; always a top-level one (one level of + * replies). Null for a top-level comment, and on comments made before + * there were replies. + */ + replyTo: string | null; deleted: boolean; createdAt: string; editedAt: string | null; @@ -22,12 +28,13 @@ export interface CommentProps { const MAX_BODY = 20_000; -/** Decisions and explanations inside a ticket. */ +/** Decisions and explanations inside a ticket, and the replies to them. */ export class Comment extends VersionedAggregateRoot implements CommentProps { public issueId!: string; public projectId!: string; public authorId!: string; public body!: string; + public replyTo: string | null = null; public deleted = false; public createdAt!: string; public editedAt: string | null = null; diff --git a/libs/api/tracker/domain/src/lib/factories/comment.factory.ts b/libs/api/tracker/domain/src/lib/factories/comment.factory.ts index 372bb75..40473e1 100644 --- a/libs/api/tracker/domain/src/lib/factories/comment.factory.ts +++ b/libs/api/tracker/domain/src/lib/factories/comment.factory.ts @@ -10,6 +10,8 @@ export class CommentFactory { projectId: string; authorId: string; body: string; + /** Top-level comment it answers. */ + replyTo?: string | null; }): Comment { const data: CommentProps = { id: newId(), @@ -17,6 +19,7 @@ export class CommentFactory { projectId: props.projectId, authorId: props.authorId, body: validCommentBody(props.body), + replyTo: props.replyTo ?? null, deleted: false, createdAt: new Date().toISOString(), editedAt: null, 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 81353b5..af1de83 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 @@ -6,6 +6,13 @@ export class CommentReadModel { projectId!: string; author!: UserRefReadModel; body!: string; + /** Top-level comment it answers; null for a top-level one. */ + replyTo!: string | null; + /** + * Deleted while it had replies: kept, without its text, so the + * conversation under it still makes sense. Gone with its last reply. + */ + deleted!: boolean; createdAt!: string; editedAt!: string | null; version!: number; diff --git a/libs/api/tracker/domain/src/lib/read-models/notification.read-model.ts b/libs/api/tracker/domain/src/lib/read-models/notification.read-model.ts index 7acaebf..23638f8 100644 --- a/libs/api/tracker/domain/src/lib/read-models/notification.read-model.ts +++ b/libs/api/tracker/domain/src/lib/read-models/notification.read-model.ts @@ -12,6 +12,8 @@ export enum NotificationKind { COMMENTED = 'COMMENTED', /** Someone wrote @you. */ MENTIONED = 'MENTIONED', + /** Someone answered a comment of yours. */ + REPLIED = 'REPLIED', /** A ticket you follow changed. */ WATCHED = 'WATCHED', /** You were added to a project. */ 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 ee6ab0d..78979f9 100644 --- a/libs/api/tracker/infrastructure/src/lib/mongo/find.repositories.ts +++ b/libs/api/tracker/infrastructure/src/lib/mongo/find.repositories.ts @@ -45,6 +45,13 @@ import { /* Query-side adapters (MongoDB). */ +/** Comments projected before there were replies have neither field. */ +const commentFromDoc = (doc: CommentDoc): CommentReadModel => ({ + ...fromDoc(doc), + replyTo: doc.replyTo ?? null, + deleted: doc.deleted ?? false, +}); + const escapeRegex = (text: string) => text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); @@ -446,7 +453,7 @@ export class MongoFindCommentsRepository extends FindCommentsRepository { async findById(id: string): Promise { const doc = await this.comments.findById(id).lean(); - return doc ? fromDoc(doc) : null; + return doc ? commentFromDoc(doc) : null; } async findByIssue( @@ -463,11 +470,15 @@ export class MongoFindCommentsRepository extends FindCommentsRepository { .lean(), this.comments.countDocuments({ issueId }), ]); - return { items: docs.map(fromDoc), total }; + return { items: docs.map(commentFromDoc), total }; } countByIssue(issueId: string): Promise { - return this.comments.countDocuments({ issueId }); + return this.comments.countDocuments({ issueId, deleted: { $ne: true } }); + } + + countReplies(commentId: string): Promise { + return this.comments.countDocuments({ replyTo: commentId }); } } diff --git a/libs/api/tracker/infrastructure/src/lib/mongo/read-model.schemas.ts b/libs/api/tracker/infrastructure/src/lib/mongo/read-model.schemas.ts index 5849ec0..c13cce8 100644 --- a/libs/api/tracker/infrastructure/src/lib/mongo/read-model.schemas.ts +++ b/libs/api/tracker/infrastructure/src/lib/mongo/read-model.schemas.ts @@ -151,6 +151,7 @@ export const CommentSchema = new Schema( { _id: String, issueId: { type: String, index: true }, + replyTo: { type: String, index: true }, author: userRef, version: Number, }, 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 b78d2ce..4d8309e 100644 --- a/libs/api/tracker/presenters/src/lib/graphql/comment.resolver.ts +++ b/libs/api/tracker/presenters/src/lib/graphql/comment.resolver.ts @@ -8,13 +8,18 @@ import { CommandResultGql, CommentGql } from './types'; export class CommentResolver { constructor(private readonly comments: CommentsService) {} - @Mutation(() => CommandResultGql) + @Mutation(() => CommandResultGql, { + description: + 'Comment on a ticket, or answer a comment (replyTo; one level of replies)', + }) addComment( @CurrentUser() me: User, @Args('issueId', { type: () => ID }) issueId: string, @Args('body') body: string, + @Args('replyTo', { type: () => ID, nullable: true }) + replyTo: string | null, ) { - return this.comments.add(me.id, issueId, body); + return this.comments.add(me.id, issueId, body, replyTo ?? null); } @Mutation(() => CommandResultGql) diff --git a/libs/api/tracker/presenters/src/lib/graphql/types.ts b/libs/api/tracker/presenters/src/lib/graphql/types.ts index c8ee272..21e538d 100644 --- a/libs/api/tracker/presenters/src/lib/graphql/types.ts +++ b/libs/api/tracker/presenters/src/lib/graphql/types.ts @@ -445,7 +445,17 @@ export class CommentGql { @Field(() => ID) id!: string; @Field(() => ID) issueId!: string; @Field(() => UserRefGql) author!: UserRefGql; - @Field() body!: string; + @Field({ description: 'Empty on a deleted comment kept for its replies' }) + body!: string; + @Field(() => ID, { + nullable: true, + description: 'The top-level comment it answers; null for a top-level one', + }) + replyTo!: string | null; + @Field({ + description: 'Deleted, but kept (without its text) while it has replies', + }) + deleted!: boolean; @Field() createdAt!: string; @Field(() => String, { nullable: true }) editedAt!: string | null; @Field(() => Int) version!: number; 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 94ddee4..9a4b184 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 @@ -955,6 +955,17 @@ export class CommentBodyDto { body!: string; } +export class AddCommentDto extends CommentBodyDto { + @ApiPropertyOptional({ + format: 'uuid', + description: + 'Comment it answers (one level: answering a reply joins its thread)', + }) + @IsOptional() + @IsUUID() + replyTo?: string; +} + // ---- Releases --------------------------------------------------------------- export class CreateReleaseDto { 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 10f0140..687795e 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 @@ -466,7 +466,21 @@ export class CommentDto { @ApiProperty({ format: 'uuid' }) id!: string; @ApiProperty({ format: 'uuid' }) issueId!: string; @ApiProperty({ type: UserRefDto }) author!: UserRefDto; - @ApiProperty() body!: string; + @ApiProperty({ + description: 'Empty on a deleted comment kept for its replies', + }) + body!: string; + @ApiProperty({ + type: String, + nullable: true, + format: 'uuid', + description: 'The top-level comment it answers; null for a top-level one', + }) + replyTo!: string | null; + @ApiProperty({ + description: 'Deleted, but kept (without its text) while it has replies', + }) + deleted!: boolean; @ApiProperty({ format: 'date-time' }) createdAt!: string; @ApiProperty({ type: String, nullable: true, format: 'date-time' }) editedAt!: string | null; diff --git a/libs/api/tracker/presenters/src/lib/http/issues.controller.ts b/libs/api/tracker/presenters/src/lib/http/issues.controller.ts index 03b537d..73d3d46 100644 --- a/libs/api/tracker/presenters/src/lib/http/issues.controller.ts +++ b/libs/api/tracker/presenters/src/lib/http/issues.controller.ts @@ -39,7 +39,7 @@ import { BulkUpdateIssuesDto, ChangeStatusDto, ChecklistItemBodyDto, - CommentBodyDto, + AddCommentDto, CreateIssueDto, IssueSearchQueryDto, LinkIssueDto, @@ -529,18 +529,21 @@ export class IssuesController { @Post('issues/:issue/comments') @ISSUE - @ApiOperation({ summary: 'Comment on the ticket' }) + @ApiOperation({ + summary: 'Comment on the ticket, or answer a comment (replyTo)', + }) @ApiCreatedResponse({ type: CommandResultDto }) @ApiCommandErrors() async addComment( @CurrentUser() me: User, @Param('issue') issue: string, - @Body() body: CommentBodyDto, + @Body() body: AddCommentDto, ) { return this.comments.add( me.id, await this.refs.issueId(me.id, issue), body.body, + body.replyTo ?? 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 e65183f..6315147 100644 --- a/libs/web/tracker/application/src/lib/events/issue.events.ts +++ b/libs/web/tracker/application/src/lib/events/issue.events.ts @@ -39,6 +39,8 @@ export const issuePageEvents = eventGroup({ body: string; /** Uploaded under the new comment once it exists. */ files?: File[]; + /** Comment it answers (one level of replies). */ + replyTo?: string | null; }>(), watchRequested: type<{ issueId: string; watching: boolean }>(), trashRequested: type<{ issueId: string; trashed: boolean }>(), 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 5aa9762..83c470e 100644 --- a/libs/web/tracker/application/src/lib/facades/issue.facade.ts +++ b/libs/web/tracker/application/src/lib/facades/issue.facade.ts @@ -92,10 +92,14 @@ export class IssueFacade { ); } - /** `files` are attached under the new comment. */ - addComment(body: string, files: File[] = []): void { + /** `files` are attached under the new comment; `replyTo` answers one. */ + addComment( + body: string, + files: File[] = [], + replyTo: string | null = null, + ): void { this.withIssue((issueId) => - this.dispatch.commentAddRequested({ issueId, body, files }), + this.dispatch.commentAddRequested({ issueId, body, files, replyTo }), ); } 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 73a5fb9..1b2356f 100644 --- a/libs/web/tracker/application/src/lib/stores/issue.store.ts +++ b/libs/web/tracker/application/src/lib/stores/issue.store.ts @@ -460,7 +460,7 @@ export const IssueStore = signalStore( mergeMap(({ payload }) => runCommand( comments - .add(payload.issueId, payload.body) + .add(payload.issueId, payload.body, payload.replyTo ?? null) .pipe( concatMap((comment) => payload.files?.length @@ -474,7 +474,9 @@ export const IssueStore = signalStore( ), payload.files?.length ? 'Comentario y adjuntos añadidos' - : 'Comentario añadido', + : payload.replyTo + ? 'Respuesta añadida' + : 'Comentario añadido', done, failed, ), diff --git a/libs/web/tracker/domain/src/lib/models/enums.ts b/libs/web/tracker/domain/src/lib/models/enums.ts index 7a35265..d68db3b 100644 --- a/libs/web/tracker/domain/src/lib/models/enums.ts +++ b/libs/web/tracker/domain/src/lib/models/enums.ts @@ -53,6 +53,7 @@ export enum NotificationKind { QA_FAILED = 'QA_FAILED', COMMENTED = 'COMMENTED', MENTIONED = 'MENTIONED', + REPLIED = 'REPLIED', WATCHED = 'WATCHED', ADDED_TO_PROJECT = 'ADDED_TO_PROJECT', REMINDER = 'REMINDER', diff --git a/libs/web/tracker/domain/src/lib/models/models.ts b/libs/web/tracker/domain/src/lib/models/models.ts index b62bf0c..e2a8c99 100644 --- a/libs/web/tracker/domain/src/lib/models/models.ts +++ b/libs/web/tracker/domain/src/lib/models/models.ts @@ -269,7 +269,12 @@ export interface Comment { id: string; issueId: string; author: UserRef; + /** Empty on a deleted comment kept for its replies. */ body: string; + /** The top-level comment it answers; null for a top-level one. */ + replyTo: string | null; + /** Deleted, but kept (without its text) while it has replies. */ + deleted: boolean; 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 596a307..d07d8d1 100644 --- a/libs/web/tracker/domain/src/lib/ports/gateways.ts +++ b/libs/web/tracker/domain/src/lib/ports/gateways.ts @@ -230,7 +230,11 @@ export abstract class SavedFiltersGateway { export abstract class CommentsGateway { abstract list(issueId: string): Observable; - abstract add(issueId: string, body: string): Observable; + abstract add( + issueId: string, + body: string, + replyTo?: string | null, + ): Observable; abstract edit(commentId: string, body: string): Observable; abstract remove(commentId: 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 52ea763..166505b 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 @@ -42,9 +42,13 @@ export class GraphqlCommentsGateway extends CommentsGateway { .pipe(map((d) => d.issue.comments.items.map(toComment))); } - add(issueId: string, body: string): Observable { + add( + issueId: string, + body: string, + replyTo: string | null = null, + ): Observable { return this.gql - .mutate(AddCommentDocument, { issueId, body }) + .mutate(AddCommentDocument, { issueId, body, replyTo }) .pipe(map((d) => toCommandResult(d.addComment))); } diff --git a/libs/web/tracker/infrastructure/src/lib/graphql/fragments.graphql b/libs/web/tracker/infrastructure/src/lib/graphql/fragments.graphql index 900aff7..7d51016 100644 --- a/libs/web/tracker/infrastructure/src/lib/graphql/fragments.graphql +++ b/libs/web/tracker/infrastructure/src/lib/graphql/fragments.graphql @@ -141,6 +141,8 @@ fragment CommentFields on Comment { id issueId body + replyTo + deleted 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 65436b0..c7dd19d 100644 --- a/libs/web/tracker/infrastructure/src/lib/graphql/generated.ts +++ b/libs/web/tracker/infrastructure/src/lib/graphql/generated.ts @@ -212,6 +212,7 @@ export type NotificationKind = | 'MENTIONED' | 'QA_FAILED' | 'REMINDER' + | 'REPLIED' | 'STATUS_CHANGED' | 'WATCHED'; @@ -554,6 +555,8 @@ export type CommentFieldsFragment = { id: string; issueId: string; body: string; + replyTo: string | null; + deleted: boolean; createdAt: string; editedAt: string | null; author: { id: string; name: string }; @@ -875,6 +878,8 @@ export type IssueCommentsQuery = { id: string; issueId: string; body: string; + replyTo: string | null; + deleted: boolean; createdAt: string; editedAt: string | null; author: { id: string; name: string }; @@ -886,6 +891,7 @@ export type IssueCommentsQuery = { export type AddCommentMutationVariables = Exact<{ issueId: string; body: string; + replyTo?: string | null | undefined; }>; export type AddCommentMutation = { @@ -1853,6 +1859,8 @@ export type CommentChangedSubscription = { id: string; issueId: string; body: string; + replyTo: string | null; + deleted: boolean; createdAt: string; editedAt: string | null; author: { id: string; name: string }; @@ -2698,6 +2706,8 @@ export const CommentFieldsFragmentDoc = { { kind: 'Field', name: { kind: 'Name', value: 'id' } }, { kind: 'Field', name: { kind: 'Name', value: 'issueId' } }, { kind: 'Field', name: { kind: 'Name', value: 'body' } }, + { kind: 'Field', name: { kind: 'Name', value: 'replyTo' } }, + { kind: 'Field', name: { kind: 'Name', value: 'deleted' } }, { kind: 'Field', name: { kind: 'Name', value: 'createdAt' } }, { kind: 'Field', name: { kind: 'Name', value: 'editedAt' } }, { @@ -5477,6 +5487,8 @@ export const IssueCommentsDocument = { { kind: 'Field', name: { kind: 'Name', value: 'id' } }, { kind: 'Field', name: { kind: 'Name', value: 'issueId' } }, { kind: 'Field', name: { kind: 'Name', value: 'body' } }, + { kind: 'Field', name: { kind: 'Name', value: 'replyTo' } }, + { kind: 'Field', name: { kind: 'Name', value: 'deleted' } }, { kind: 'Field', name: { kind: 'Name', value: 'createdAt' } }, { kind: 'Field', name: { kind: 'Name', value: 'editedAt' } }, { @@ -5527,6 +5539,14 @@ export const AddCommentDocument = { }, }, }, + { + kind: 'VariableDefinition', + variable: { + kind: 'Variable', + name: { kind: 'Name', value: 'replyTo' }, + }, + type: { kind: 'NamedType', name: { kind: 'Name', value: 'ID' } }, + }, ], selectionSet: { kind: 'SelectionSet', @@ -5551,6 +5571,14 @@ export const AddCommentDocument = { name: { kind: 'Name', value: 'body' }, }, }, + { + kind: 'Argument', + name: { kind: 'Name', value: 'replyTo' }, + value: { + kind: 'Variable', + name: { kind: 'Name', value: 'replyTo' }, + }, + }, ], selectionSet: { kind: 'SelectionSet', @@ -11232,6 +11260,8 @@ export const CommentChangedDocument = { { kind: 'Field', name: { kind: 'Name', value: 'id' } }, { kind: 'Field', name: { kind: 'Name', value: 'issueId' } }, { kind: 'Field', name: { kind: 'Name', value: 'body' } }, + { kind: 'Field', name: { kind: 'Name', value: 'replyTo' } }, + { kind: 'Field', name: { kind: 'Name', value: 'deleted' } }, { 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 7615238..11a17ba 100644 --- a/libs/web/tracker/infrastructure/src/lib/graphql/issues.graphql +++ b/libs/web/tracker/infrastructure/src/lib/graphql/issues.graphql @@ -112,8 +112,8 @@ query IssueComments($idOrKey: String!) { } } -mutation AddComment($issueId: ID!, $body: String!) { - addComment(issueId: $issueId, body: $body) { +mutation AddComment($issueId: ID!, $body: String!, $replyTo: ID) { + addComment(issueId: $issueId, body: $body, replyTo: $replyTo) { ...CommandResultFields } } diff --git a/libs/web/tracker/presenters/src/lib/pages/issue.page.scss b/libs/web/tracker/presenters/src/lib/pages/issue.page.scss index e3982e8..2feb3c5 100644 --- a/libs/web/tracker/presenters/src/lib/pages/issue.page.scss +++ b/libs/web/tracker/presenters/src/lib/pages/issue.page.scss @@ -531,3 +531,32 @@ a.ts-key { border-radius: 0.5rem; background: color-mix(in srgb, var(--ts-c-red, #dc2626) 12%, transparent); } + +// One level of replies under each comment. +.ts-replies { + display: grid; + gap: 0.75rem; + margin: -0.25rem 0 0.5rem 2.75rem; + padding-left: 0.875rem; + border-left: 2px solid var(--ts-line); + + &:empty { + display: none; + } +} + +.ts-comment__gone { + margin: 0; + font-style: italic; +} + +.ts-reply__open { + justify-self: start; + font-size: 0.8125rem; +} + +.ts-reply__actions { + display: flex; + gap: 0.75rem; + align-items: center; +} 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 9ed80bf..4c6f883 100644 --- a/libs/web/tracker/presenters/src/lib/pages/issue.page.ts +++ b/libs/web/tracker/presenters/src/lib/pages/issue.page.ts @@ -1,3 +1,4 @@ +import { NgTemplateOutlet } from '@angular/common'; import { ChangeDetectionStrategy, Component, @@ -71,6 +72,7 @@ const MAX_FILE_BYTES = 25 * 1024 * 1024; selector: 'ts-issue-page', changeDetection: ChangeDetectionStrategy.OnPush, imports: [ + NgTemplateOutlet, RouterLink, FormField, AttachmentItem, @@ -512,22 +514,24 @@ const MAX_FILE_BYTES = 25 * 1024 * 1024;

Comentarios - {{ facade.comments().length }} + {{ commentCount() }}

- @for (comment of facade.comments(); track comment.id) { +
- {{ comment.author.name }} + {{ + comment.deleted ? '—' : comment.author.name + }} {{ ago(comment.createdAt) }}{{ comment.editedAt ? ' · editado' : '' }} - @if (comment.author.id === facade.me()) { + @if (!comment.deleted && comment.author.id === facade.me()) { } @if ( - comment.author.id === facade.me() || project.canManage() + !comment.deleted && + (comment.author.id === facade.me() || project.canManage()) ) { }
+ @if (comment.deleted) { +

Comentario borrado

+ } @else {

@for (part of segments(comment.body); track $index) { @if ('mention' in part) { @@ -561,7 +569,8 @@ const MAX_FILE_BYTES = 25 * 1024 * 1024; } }

- @if (filesOf(comment).length) { + } + @if (!comment.deleted && filesOf(comment).length) {
@for (file of filesOf(comment); track file.id) {
+ + @for (thread of threads(); track thread.comment.id) { + +
+ @for (reply of thread.replies; track reply.id) { + + } + @if (replyingTo() === thread.comment.id) { +
+ +
+ + +
+ + } @else if (project.canContribute() && !thread.comment.deleted) { + + } +
} @if (project.canContribute()) {
(IssueLinkType.RELATES_TO); protected readonly linkTarget = signal(''); protected readonly commentBody = signal(''); + /** The comment being answered, and the answer being written. */ + protected readonly replyingTo = signal(null); + protected readonly replyBody = signal(''); + /** Top-level comments, each with its replies (oldest first). */ + protected readonly threads = computed(() => { + const comments = this.facade.comments(); + const ids = new Set(comments.map((c) => c.id)); + const replies = new Map(); + for (const c of comments) + if (c.replyTo && ids.has(c.replyTo)) + replies.set(c.replyTo, [...(replies.get(c.replyTo) ?? []), c]); + // A reply whose comment did not load stands on its own. + return comments + .filter((c) => !c.replyTo || !ids.has(c.replyTo)) + .map((comment) => ({ comment, replies: replies.get(comment.id) ?? [] })); + }); + protected readonly commentCount = computed( + () => this.facade.comments().filter((c) => !c.deleted).length, + ); protected readonly commentFiles = signal([]); protected readonly segments = mentionSegments; /** Members matching what is typed after an `@` at the end of the comment. */ @@ -1126,6 +1205,19 @@ export class IssuePage { this.commentFiles.set([]); } + protected startReply(comment: Comment): void { + this.replyBody.set(''); + this.replyingTo.set(comment.id); + } + + protected reply(comment: Comment): void { + const body = this.replyBody().trim(); + if (!body) return; + this.facade.addComment(body, [], comment.id); + this.replyBody.set(''); + this.replyingTo.set(null); + } + protected mention(user: { id: string; name: string }): void { this.commentBody.update((text) => insertMention(text, user)); } diff --git a/libs/web/tracker/presenters/src/lib/ui/notifications-bell.ts b/libs/web/tracker/presenters/src/lib/ui/notifications-bell.ts index f2008e4..24612fd 100644 --- a/libs/web/tracker/presenters/src/lib/ui/notifications-bell.ts +++ b/libs/web/tracker/presenters/src/lib/ui/notifications-bell.ts @@ -27,6 +27,7 @@ const ICON: Record = { [NotificationKind.QA_FAILED]: 'exclamation', [NotificationKind.COMMENTED]: 'chat', [NotificationKind.MENTIONED]: 'chat', + [NotificationKind.REPLIED]: 'chat', [NotificationKind.WATCHED]: 'focus', [NotificationKind.ADDED_TO_PROJECT]: 'briefcase', [NotificationKind.REMINDER]: 'calendar',