Compare commits

...

2 Commits

Author SHA1 Message Date
f6417248c7 feat: tickets I follow, and more news for followers
"Que sigo" on the home page lists the open tickets I follow, whoever
works on them; `watching` filters my issues (REST, GraphQL, MCP) and a
project's search (none without a session).

Followers now also hear when a ticket's priority or due date changes,
and when it enters or leaves a sprint. Smaller edits (title, labels…)
still do not notify.

Docs (with the REPLIED row the replies change missed), Word and PDF
updated.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 11:29:17 +02:00
ea4ef82239 feat: replies to comments
A comment can answer another one (replyTo), one level deep: answering a
reply joins the same thread, and it must be on the same ticket. Whoever
wrote the comment gets a new REPLIED notification, and the activity says
"respondió a un comentario".

A comment deleted while it has replies stays, without its text
(deleted: true), so the thread still reads; it goes with its last reply
and does not count in commentCount.

The ticket page shows threads with their replies indented and a
"Responder" box; REST, GraphQL and the MCP (add_comment, get_issue) take
and show replyTo. Existing comments are unchanged. Docs, Word and PDF
updated.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 11:14:37 +02:00
57 changed files with 1125 additions and 11605 deletions

View File

@@ -2018,6 +2018,15 @@
"type": "boolean"
}
},
{
"name": "watching",
"required": false,
"in": "query",
"description": "Only the tickets I follow (with assignedToMe=false, whoever they are assigned to)",
"schema": {
"type": "boolean"
}
},
{
"name": "status",
"required": false,
@@ -2278,6 +2287,15 @@
"type": "boolean"
}
},
{
"name": "watching",
"required": false,
"in": "query",
"description": "Only the tickets I follow (none without a token)",
"schema": {
"type": "boolean"
}
},
{
"name": "dueFrom",
"required": false,
@@ -4406,7 +4424,7 @@
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/CommentBodyDto"
"$ref": "#/components/schemas/AddCommentDto"
}
}
}
@@ -4491,7 +4509,7 @@
"pireon": []
}
],
"summary": "Comment on the ticket",
"summary": "Comment on the ticket, or answer a comment (replyTo)",
"tags": [
"Issues"
]
@@ -11423,7 +11441,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 +11472,8 @@
"issueId",
"author",
"body",
"replyTo",
"deleted",
"createdAt",
"editedAt",
"version"
@@ -11466,13 +11497,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 +11533,19 @@
"file"
]
},
"CommentBodyDto": {
"type": "object",
"properties": {
"body": {
"type": "string",
"example": "Reproducido en Safari 17.",
"maxLength": 20000
}
},
"required": [
"body"
]
},
"ReleaseStatus": {
"type": "string",
"enum": [
@@ -11929,6 +11978,7 @@
"QA_FAILED",
"COMMENTED",
"MENTIONED",
"REPLIED",
"WATCHED",
"ADDED_TO_PROJECT",
"REMINDER"

View File

@@ -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!
}
@@ -500,6 +508,9 @@ input IssueFilterInput {
statuses: [String!]
types: [String!]
unassigned: Boolean
"""Only the tickets I follow (none when anonymous)"""
watching: Boolean
}
type IssueLink {
@@ -602,7 +613,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 +791,7 @@ enum NotificationKind {
MENTIONED
QA_FAILED
REMINDER
REPLIED
STATUS_CHANGED
WATCHED
}
@@ -947,6 +963,9 @@ type Query {
"""Matches key, title or description (global search)"""
search: String
statuses: [String!]
"""Only the tickets I follow"""
watching: Boolean! = false
): IssuePage!
"""My notifications, newest first, and how many are unread"""

View File

@@ -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"];
@@ -3916,6 +3933,8 @@ export interface operations {
offset?: number;
/** @description true: tickets assigned to me. false: any ticket of my projects (e.g. ?assignedToMe=false&category=QA) */
assignedToMe?: boolean;
/** @description Only the tickets I follow (with assignedToMe=false, whoever they are assigned to) */
watching?: boolean;
/** @description Status ids. Repeat the parameter or separate with commas */
status?: string[];
/** @description Whatever each project calls its statuses */
@@ -4000,6 +4019,8 @@ export interface operations {
sprintId?: string;
/** @description Only tickets in no sprint */
noSprint?: boolean;
/** @description Only the tickets I follow (none without a token) */
watching?: boolean;
/** @description Due on or after this day */
dueFrom?: string;
/** @description Due on or before this day */
@@ -5497,7 +5518,7 @@ export interface operations {
};
requestBody: {
content: {
"application/json": components["schemas"]["CommentBodyDto"];
"application/json": components["schemas"]["AddCommentDto"];
};
};
responses: {

View File

@@ -67,9 +67,15 @@ export class IssuesResolver {
@Tool({
name: 'my_issues',
description:
"Tickets across all the user's projects: assigned to them (default) or anyone's, filtered by status category (e.g. QA to see what waits for testing).",
"Tickets across all the user's projects: assigned to them (default) or anyone's, filtered by status category (e.g. QA to see what waits for testing); watching: only the ones they follow.",
paramsSchema: z.object({
assignedToMe: z.boolean().default(true),
watching: z
.boolean()
.default(false)
.describe(
'Only the tickets the user follows (use with assignedToMe false)',
),
categories: z
.array(CATEGORY)
.optional()
@@ -81,6 +87,7 @@ export class IssuesResolver {
mine(
params: {
assignedToMe: boolean;
watching: boolean;
categories?: z.infer<typeof CATEGORY>[];
limit: number;
},
@@ -92,6 +99,7 @@ export class IssuesResolver {
params: {
query: {
assignedToMe: params.assignedToMe,
watching: params.watching,
category: params.categories ?? ['TODO', 'IN_PROGRESS', 'QA'],
limit: params.limit,
},
@@ -127,6 +135,10 @@ export class IssuesResolver {
.boolean()
.optional()
.describe('Only tickets in no sprint (the backlog)'),
watching: z
.boolean()
.optional()
.describe('Only the tickets the user follows'),
dueFrom: z.string().optional().describe('Due on or after (ISO date)'),
dueTo: z.string().optional().describe('Due on or before (ISO date)'),
sort: z
@@ -154,6 +166,7 @@ export class IssuesResolver {
parent?: string;
sprintId?: string;
noSprint?: boolean;
watching?: boolean;
dueFrom?: string;
dueTo?: string;
sort: 'RECENT' | 'RANK';
@@ -181,6 +194,7 @@ export class IssuesResolver {
parent: params.parent,
sprintId: params.sprintId,
noSprint: params.noSprint,
watching: params.watching,
dueFrom: params.dueFrom,
dueTo: params.dueTo,
sort: params.sort,
@@ -224,9 +238,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 +426,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 },
}),
),
);

View File

@@ -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 |
@@ -27,8 +27,8 @@
| 🗺️ Hoja de ruta | Las épicas en el tiempo (fecha de inicio y límite), con los sprints y las releases como referencias |
| 🌐 Web del colectivo | La página de cada proyecto en pireon.dev enseña su hoja de ruta, versiones, sprint y tickets para recién llegados (`good-first-issue`) |
| 🧩 Desglose | Épicas → tickets → subtareas (tres niveles), con su progreso; y una checklist dentro de cada ticket para los pasos pequeños |
| 👀 Seguir | Cualquier miembro sigue los tickets que le interesan; 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 |
| 👀 Seguir | Cualquier miembro sigue los tickets que le interesan y los ve juntos en «Que sigo»; quien comenta, los 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) |

View File

@@ -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.
@@ -233,6 +237,7 @@ ningún agregado, solo cada uno lee y descarta las suyas.
| Un ticket cambia de estado | Quien lo tiene asignado y quien lo abrió | `STATUS_CHANGED` |
| Un ticket vuelve de QA sin pasar a hecho | Los mismos, con el motivo | `QA_FAILED` |
| Alguien comenta | Quien lo tiene asignado y quien lo abrió | `COMMENTED` |
| Alguien responde a un comentario | Quien escribió el comentario | `REPLIED` |
| Te añaden a un proyecto | La persona añadida | `ADDED_TO_PROJECT` |
**Recordatorios** (`REMINDER`): cada hora la API mira los tickets sin terminar con fecha límite y avisa a quien lo
@@ -246,7 +251,11 @@ Nadie recibe aviso de lo que hizo él mismo, y cada persona recibe uno por event
**Menciones**: se escriben `@[Nombre](id-de-usuario)` (la web las inserta al escribir `@`); el id dice a quién avisar y
el nombre es lo que se ve, así que renombrar a alguien no rompe comentarios antiguos. Solo avisan a miembros del
proyecto. **Seguir**: cualquier miembro, también los lectores, puede seguir un ticket (`watchIssue`/`unwatchIssue`,
eventos `IssueWatchedEvent`/`IssueUnwatchedEvent`), y quien comenta pasa a seguirlo (`CommentersFollowSaga`). El id es
eventos `IssueWatchedEvent`/`IssueUnwatchedEvent`), y quien comenta pasa a seguirlo (`CommentersFollowSaga`). Quien
sigue un ticket (`WATCHED`) sabe cuándo cambia de estado, se asigna, se comenta, cambia su prioridad o su fecha límite,
o entra o sale de un sprint; las ediciones menores (título, etiquetas…) no avisan. «Los que sigo»: `watching` en
`myIssues` / `GET /api/me/issues` y en la búsqueda de un proyecto (`issues(filter: { watching: true })`,
`?watching=true`; sin sesión, ninguno). El id es
`<agregado>:<versión>:<persona>`, así que reentregar un evento no duplica avisos. Llegan en vivo por la subscription
`notificationAdded` y, si la instalación tiene `SMTP_URL`, también por correo a quien lo active (`email`, desactivado
por defecto); un correo que falla se registra y no bloquea el stream.
@@ -334,9 +343,9 @@ 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) |
| `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) |

View File

@@ -49,7 +49,7 @@ flowchart LR
| | `get_project_stats` | Cifras de seguimiento |
| | `get_project_analytics` | Estadísticas de los últimos días (creados/resueltos, flujo, tiempos, throughput, QA) |
| | `get_project_activity` | Actividad reciente |
| Tickets | `my_issues` | Mi trabajo en todos los proyectos (o de cualquiera, por categoría: p. ej. lo que espera QA) |
| Tickets | `my_issues` | Mi trabajo en todos los proyectos (o de cualquiera, por categoría; o los que sigo) |
| | `search_issues` | Buscar por texto y filtros (épica, sprint, fechas…); el backlog: `noSprint` y orden `RANK` |
| | `get_issue` | Detalle: épica, lista de comprobación, relaciones, commits, seguidores, transiciones, comentarios |
| | `create_issue` | Crear un ticket (también colgado de una épica) |
@@ -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 |

Binary file not shown.

Binary file not shown.

View File

@@ -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<string> {
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;
}
}

View File

@@ -6,6 +6,8 @@ export class AddCommentCommand extends Command<CommandResult> {
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();
}

View File

@@ -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);

View File

@@ -15,9 +15,14 @@ export class CommentsService {
private readonly queryBus: QueryBus,
) {}
add(actorId: string, issueId: string, body: string): Promise<CommandResult> {
add(
actorId: string,
issueId: string,
body: string,
replyTo: string | null = null,
): Promise<CommandResult> {
return this.commandBus.execute(
new AddCommentCommand(actorId, issueId, body),
new AddCommentCommand(actorId, issueId, body, replyTo),
);
}

View File

@@ -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)

View File

@@ -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),

View File

@@ -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);
}
}

View File

@@ -3,7 +3,10 @@ import {
CommentAddedEvent,
IssueAssignedEvent,
IssueCreatedEvent,
IssuePriority,
IssueSprintChangedEvent,
IssueStatusChangedEvent,
IssueUpdatedEvent,
mentionedUserIds,
NotificationKind,
ProjectMemberAddedEvent,
@@ -11,13 +14,33 @@ 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 { FindSprintsRepository } from '../ports/find-sprints.repository';
import { UserDirectory } from '../ports/user-directory';
import { NotificationDraft, Notifier } from './notifier';
/* Read by people, like the activity feed: Spanish. */
/** Same labels as the web app. */
const PRIORITY: Record<IssuePriority, string> = {
[IssuePriority.CRITICAL]: 'Crítica',
[IssuePriority.HIGH]: 'Alta',
[IssuePriority.MEDIUM]: 'Media',
[IssuePriority.LOW]: 'Baja',
[IssuePriority.LOWEST]: 'Mínima',
};
/** 2026-11-14 → 14 de noviembre de 2026. */
const day = (date: string) =>
new Date(`${date.slice(0, 10)}T00:00:00Z`).toLocaleDateString('es-ES', {
day: 'numeric',
month: 'long',
year: 'numeric',
timeZone: 'UTC',
});
const excerpt = (text: string, max = 120) => {
const flat = withoutMentionMarkup(text).replace(/\s+/g, ' ').trim();
return flat.length > max ? `${flat.slice(0, max - 1)}…` : flat;
@@ -34,6 +57,8 @@ const excerpt = (text: string, max = 120) => {
IssueCreatedEvent,
IssueAssignedEvent,
IssueStatusChangedEvent,
IssueUpdatedEvent,
IssueSprintChangedEvent,
CommentAddedEvent,
ProjectMemberAddedEvent,
)
@@ -43,6 +68,8 @@ export class NotificationsEventHandler implements IEventHandler<TrackerEvent> {
private readonly findIssues: FindIssuesRepository,
private readonly findProjects: FindProjectsRepository,
private readonly users: UserDirectory,
private readonly findComments: FindCommentsRepository,
private readonly findSprints: FindSprintsRepository,
) {}
async handle(event: TrackerEvent): Promise<void> {
@@ -154,6 +181,16 @@ export class NotificationsEventHandler implements IEventHandler<TrackerEvent> {
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({
@@ -171,6 +208,14 @@ export class NotificationsEventHandler implements IEventHandler<TrackerEvent> {
}
}
// Followers also hear of the changes that move their plans.
const news = await this.followerNews(event, actor, ticket);
if (news) {
for (const userId of watcherIds) {
drafts.push({ userId, kind: NotificationKind.WATCHED, summary: news });
}
}
await this.notifier.deliver(
event,
{
@@ -182,4 +227,34 @@ export class NotificationsEventHandler implements IEventHandler<TrackerEvent> {
drafts,
);
}
/**
* What a follower should hear of a change of priority, due date or
* sprint; null for the other edits (title, labels…), too small to tell.
*/
private async followerNews(
event: TrackerEvent,
actor: string,
ticket: string,
): Promise<string | null> {
if (event instanceof IssueUpdatedEvent) {
const { priority, dueDate } = event.changes;
const parts: string[] = [];
if (priority) parts.push(`prioridad ${PRIORITY[priority]}`);
if (dueDate !== undefined)
parts.push(
dueDate ? `fecha límite el ${day(dueDate)}` : 'sin fecha límite',
);
return parts.length
? `${actor} cambió ${ticket}: ${parts.join(', ')}`
: null;
}
if (event instanceof IssueSprintChangedEvent) {
if (!event.sprintId)
return `${actor} sacó ${ticket} del sprint y lo devolvió al backlog`;
const sprint = await this.findSprints.findById(event.sprintId);
return `${actor} metió ${ticket} en ${sprint ? `el sprint ${sprint.name}` : 'un sprint'}`;
}
return null;
}
}

View File

@@ -1,7 +1,10 @@
import {
CommentAddedEvent,
IssueAssignedEvent,
IssuePriority,
IssueSprintChangedEvent,
IssueStatusChangedEvent,
IssueUpdatedEvent,
NotificationKind,
NotificationReadModel,
NotificationSettingsReadModel,
@@ -12,7 +15,9 @@ 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 { FindSprintsRepository } from '../ports/find-sprints.repository';
import { FindProjectsRepository } from '../ports/find-projects.repository';
import { NotificationMailer } from '../ports/notification-mailer';
import { NotificationsRepository } from '../ports/notifications.repository';
@@ -100,6 +105,17 @@ 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,
{
findById: async (id: string) =>
id === 's-14' ? { id, name: 'Sprint 14' } : null,
} as unknown as FindSprintsRepository,
);
return { repo, published, mailed, handler };
}
@@ -163,6 +179,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 +202,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 +218,86 @@ 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('tells followers of a new priority, due date or sprint, not of smaller edits', async () => {
const { handler, repo } = setup();
await handler.handle(
at(
new IssueUpdatedEvent('ana', 'i-1', 'p-1', {
priority: IssuePriority.HIGH,
dueDate: '2026-11-14',
}),
),
);
await handler.handle(
at(new IssueUpdatedEvent('ana', 'i-1', 'p-1', { title: 'Login' }), 3),
);
await handler.handle(
at(new IssueSprintChangedEvent('ana', 'i-1', 'p-1', 's-14', null), 4),
);
await handler.handle(
at(new IssueSprintChangedEvent('ana', 'i-1', 'p-1', null, 's-14'), 5),
);
expect(repo.all.map((n) => [n.userId, n.kind, n.summary])).toEqual([
[
'pm',
NotificationKind.WATCHED,
'user ana cambió WEB-1: Login roto: prioridad Alta, fecha límite el 14 de noviembre de 2026',
],
[
'bob',
NotificationKind.WATCHED,
'user ana cambió WEB-1: Login roto: prioridad Alta, fecha límite el 14 de noviembre de 2026',
],
[
'pm',
NotificationKind.WATCHED,
'user ana metió WEB-1: Login roto en el sprint Sprint 14',
],
[
'bob',
NotificationKind.WATCHED,
'user ana metió WEB-1: Login roto en el sprint Sprint 14',
],
[
'pm',
NotificationKind.WATCHED,
'user ana sacó WEB-1: Login roto del sprint y lo devolvió al backlog',
],
[
'bob',
NotificationKind.WATCHED,
'user ana sacó WEB-1: Login roto del sprint y lo devolvió al backlog',
],
]);
});
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');

View File

@@ -8,5 +8,7 @@ export abstract class FindCommentsRepository {
limit: number,
offset: number,
): Promise<Page<CommentReadModel>>;
/** Comments still standing (deleted ones kept for their replies do not count). */
abstract countByIssue(issueId: string): Promise<number>;
abstract countReplies(commentId: string): Promise<number>;
}

View File

@@ -33,6 +33,10 @@ export interface IssueFilter {
dueTo?: string;
/** Only tickets in no sprint (wins over `sprintId`). */
noSprint?: boolean;
/** Only the ones the viewer follows (the query handler turns it into `watcherId`). */
watching?: boolean;
/** Only the ones this person follows. */
watcherId?: string;
/** RECENT (default): last updated first. RANK: backlog order. */
sort?: IssueSort;
search?: string;
@@ -44,6 +48,8 @@ export interface IssueFilter {
export interface CrossProjectIssueFilter {
projectIds: string[];
assigneeId?: string;
/** Only the ones this person follows. */
watcherId?: string;
statuses?: string[];
categories?: StatusCategory[];
search?: string;

View File

@@ -15,8 +15,12 @@ export class SearchIssuesQueryHandler
async execute(query: SearchIssuesQuery): Promise<Page<IssueReadModel>> {
await this.access.assertCanView(query.filter.projectId, query.viewerId);
const { watching, ...filter } = query.filter;
// Someone anonymous follows nothing.
if (watching && !query.viewerId) return { items: [], total: 0 };
return this.findIssues.search({
...query.filter,
...filter,
watcherId: watching ? (query.viewerId ?? undefined) : undefined,
limit: Math.min(query.filter.limit, 200),
});
}

View File

@@ -19,6 +19,7 @@ export class SearchMyIssuesQueryHandler
return this.findIssues.searchAcross({
projectIds: projects.map((project) => project.id),
assigneeId: query.filter.assignedToMe ? query.viewerId : undefined,
watcherId: query.filter.watching ? query.viewerId : undefined,
statuses: query.filter.statuses,
categories: query.filter.categories,
search: query.filter.search?.trim().slice(0, 200) || undefined,

View File

@@ -8,6 +8,8 @@ import { Page } from '../ports/find-issues.repository';
export interface MyIssuesFilter {
/** true: assigned to the viewer. false: any ticket of the viewer's projects. */
assignedToMe: boolean;
/** Only the tickets the viewer follows. */
watching?: boolean;
statuses?: string[];
/** e.g. TODO + IN_PROGRESS + QA = still open, whatever the project calls them. */
categories?: StatusCategory[];

View File

@@ -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,

View File

@@ -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;

View File

@@ -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,

View File

@@ -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;

View File

@@ -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. */

View File

@@ -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, '\\$&');
@@ -159,6 +166,7 @@ export class MongoFindIssuesRepository extends FindIssuesRepository {
trashedAt: null,
};
if (filter.assigneeId) query['assignee.id'] = filter.assigneeId;
if (filter.watcherId) query['watchers.id'] = filter.watcherId;
if (filter.statuses?.length) query['status'] = { $in: filter.statuses };
if (filter.categories?.length)
query['statusCategory'] = { $in: filter.categories };
@@ -424,6 +432,7 @@ function toMongoFilter(filter: IssueFilter): FilterQuery<IssueDoc> {
else if (filter.sprintId) query['sprint.id'] = filter.sprintId;
if (filter.unassigned) query['assignee'] = null;
else if (filter.assigneeId) query['assignee.id'] = filter.assigneeId;
if (filter.watcherId) query['watchers.id'] = filter.watcherId;
if (filter.search?.trim()) {
const pattern = new RegExp(escapeRegex(filter.search.trim()), 'i');
query.$or = [
@@ -446,7 +455,7 @@ export class MongoFindCommentsRepository extends FindCommentsRepository {
async findById(id: string): Promise<CommentReadModel | null> {
const doc = await this.comments.findById(id).lean<CommentDoc>();
return doc ? fromDoc(doc) : null;
return doc ? commentFromDoc(doc) : null;
}
async findByIssue(
@@ -463,11 +472,15 @@ export class MongoFindCommentsRepository extends FindCommentsRepository {
.lean<CommentDoc[]>(),
this.comments.countDocuments({ issueId }),
]);
return { items: docs.map(fromDoc), total };
return { items: docs.map(commentFromDoc), total };
}
countByIssue(issueId: string): Promise<number> {
return this.comments.countDocuments({ issueId });
return this.comments.countDocuments({ issueId, deleted: { $ne: true } });
}
countReplies(commentId: string): Promise<number> {
return this.comments.countDocuments({ replyTo: commentId });
}
}

View File

@@ -151,6 +151,7 @@ export const CommentSchema = new Schema<CommentDoc>(
{
_id: String,
issueId: { type: String, index: true },
replyTo: { type: String, index: true },
author: userRef,
version: Number,
},

View File

@@ -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)

View File

@@ -508,6 +508,13 @@ export class IssueFilterInput {
@IsOptional()
@IsBoolean()
noSprint?: boolean;
@Field({
nullable: true,
description: 'Only the tickets I follow (none when anonymous)',
})
@IsOptional()
@IsBoolean()
watching?: boolean;
@Field({
nullable: true,
description: 'Due on or after this day (YYYY-MM-DD)',

View File

@@ -98,6 +98,11 @@ export class IssueResolver {
myIssues(
@CurrentUser() me: User,
@Args('assignedToMe', { defaultValue: true }) assignedToMe: boolean,
@Args('watching', {
defaultValue: false,
description: 'Only the tickets I follow',
})
watching: boolean,
@Args('statuses', { type: () => [String], nullable: true })
statuses: string[] | undefined,
@Args('categories', { type: () => [StatusCategory], nullable: true })
@@ -113,6 +118,7 @@ export class IssueResolver {
) {
return this.issuesService.mine(me.id, {
assignedToMe,
watching,
statuses: statuses ?? undefined,
categories: categories ?? undefined,
search: search ?? undefined,

View File

@@ -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;

View File

@@ -389,6 +389,16 @@ export class MyIssuesQueryDto extends PageQueryDto {
@IsBoolean()
assignedToMe = true;
@ApiPropertyOptional({
type: Boolean,
description:
'Only the tickets I follow (with assignedToMe=false, whoever they are assigned to)',
})
@IsOptional()
@Transform(toBoolean)
@IsBoolean()
watching?: boolean;
@ApiPropertyOptional({
type: [String],
description: 'Status ids. Repeat the parameter or separate with commas',
@@ -509,6 +519,15 @@ export class IssueSearchQueryDto extends PageQueryDto {
@IsBoolean()
noSprint?: boolean;
@ApiPropertyOptional({
type: Boolean,
description: 'Only the tickets I follow (none without a token)',
})
@IsOptional()
@Transform(toBoolean)
@IsBoolean()
watching?: boolean;
@ApiPropertyOptional({
format: 'date',
description: 'Due on or after this day',
@@ -955,6 +974,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 {

View File

@@ -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;

View File

@@ -39,7 +39,7 @@ import {
BulkUpdateIssuesDto,
ChangeStatusDto,
ChecklistItemBodyDto,
CommentBodyDto,
AddCommentDto,
CreateIssueDto,
IssueSearchQueryDto,
LinkIssueDto,
@@ -128,6 +128,7 @@ export class IssuesController {
async mine(@CurrentUser() me: User, @Query() query: MyIssuesQueryDto) {
const page = await this.issues.mine(me.id, {
assignedToMe: query.assignedToMe,
watching: query.watching,
statuses: query.status,
categories: query.category,
search: query.search,
@@ -164,6 +165,7 @@ export class IssuesController {
labels: query.label,
sprintId: query.sprintId,
noSprint: query.noSprint,
watching: query.watching,
dueFrom: query.dueFrom,
dueTo: query.dueTo,
sort: query.sort,
@@ -529,18 +531,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,
);
}

View File

@@ -16,6 +16,7 @@ export const homeApiEvents = eventGroup({
loaded: type<{
mine: IssuePage;
awaitingQa: IssuePage;
following: IssuePage;
releases: Release[];
}>(),
loadFailed: type<string>(),

View File

@@ -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 }>(),

View File

@@ -390,9 +390,12 @@ describe('HomeFacade', () => {
({ id: key, key, status, statusCategory, title: key }) as never;
it('loads my work and groups it by category, QA failures first', () => {
const mine = vi.fn((filter: { assignedToMe: boolean }) =>
const mine = vi.fn(
(filter: { assignedToMe: boolean; watching?: boolean }) =>
of(
filter.assignedToMe
filter.watching
? { items: [issue('API-7', 'IN_REVIEW', 'IN_PROGRESS')], total: 1 }
: filter.assignedToMe
? {
items: [
issue('WEB-1', 'IN_PROGRESS', 'IN_PROGRESS'),
@@ -437,6 +440,10 @@ describe('HomeFacade', () => {
]);
expect(home.qaFailed()).toBe(1);
expect(home.awaitingQaTotal()).toBe(1);
expect(mine).toHaveBeenCalledWith(
expect.objectContaining({ assignedToMe: false, watching: true }),
);
expect(home.following().map((i) => i.key)).toEqual(['API-7']);
expect(home.releases().map((r) => r.name)).toEqual(['v1.4.0']);
});
});

View File

@@ -18,6 +18,9 @@ export class HomeFacade {
/** Tickets of my projects in a QA-category status (anyone's). */
readonly awaitingQa = this.store.awaitingQa;
readonly awaitingQaTotal = this.store.awaitingQaTotal;
/** Open tickets I follow, whoever works on them. */
readonly following = this.store.following;
readonly followingTotal = this.store.followingTotal;
/** Planned releases of my projects, soonest first. */
readonly releases = this.store.releases;
/** False until the first load: show skeletons, not empty states. */

View File

@@ -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 }),
);
}

View File

@@ -32,6 +32,8 @@ interface HomeState {
mineTotal: number;
awaitingQa: IssueSummary[];
awaitingQaTotal: number;
following: IssueSummary[];
followingTotal: number;
releases: Release[];
loaded: boolean;
loading: boolean;
@@ -40,7 +42,7 @@ interface HomeState {
/**
* "My work" across every project I belong to: my open tickets, tickets
* waiting for QA and upcoming releases. Root-scoped, so coming back to the
* waiting for QA, the open ones I follow and upcoming releases. Root-scoped, so coming back to the
* home page shows the last data at once while it refreshes.
*/
export const HomeStore = signalStore(
@@ -50,6 +52,8 @@ export const HomeStore = signalStore(
mineTotal: 0,
awaitingQa: [],
awaitingQaTotal: 0,
following: [],
followingTotal: 0,
releases: [],
loaded: false,
loading: false,
@@ -82,6 +86,8 @@ export const HomeStore = signalStore(
mineTotal: payload.mine.total,
awaitingQa: payload.awaitingQa.items,
awaitingQaTotal: payload.awaitingQa.total,
following: payload.following.items,
followingTotal: payload.following.total,
releases: payload.releases,
loaded: true,
loading: false,
@@ -111,6 +117,12 @@ export const HomeStore = signalStore(
categories: [StatusCategory.QA],
limit: 20,
}),
following: issues.mine({
assignedToMe: false,
watching: true,
categories: [...OPEN_CATEGORIES],
limit: 20,
}),
releases: releases.upcoming(5),
}).pipe(
mapResponse({

View File

@@ -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,6 +474,8 @@ export const IssueStore = signalStore(
),
payload.files?.length
? 'Comentario y adjuntos añadidos'
: payload.replyTo
? 'Respuesta añadida'
: 'Comentario añadido',
done,
failed,

View File

@@ -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',

View File

@@ -72,6 +72,8 @@ export interface CreateReleaseInput {
export interface MyIssuesFilter {
/** true: assigned to me. false: any ticket of my projects. */
assignedToMe: boolean;
/** Only the tickets I follow. */
watching?: boolean;
categories?: StatusCategory[];
/** Matches key, title or description. */
search?: string;

View File

@@ -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;
}

View File

@@ -230,7 +230,11 @@ export abstract class SavedFiltersGateway {
export abstract class CommentsGateway {
abstract list(issueId: string): Observable<Comment[]>;
abstract add(issueId: string, body: string): Observable<CommandResult>;
abstract add(
issueId: string,
body: string,
replyTo?: string | null,
): Observable<CommandResult>;
abstract edit(commentId: string, body: string): Observable<CommandResult>;
abstract remove(commentId: string): Observable<CommandResult>;
}

View File

@@ -42,9 +42,13 @@ export class GraphqlCommentsGateway extends CommentsGateway {
.pipe(map((d) => d.issue.comments.items.map(toComment)));
}
add(issueId: string, body: string): Observable<CommandResult> {
add(
issueId: string,
body: string,
replyTo: string | null = null,
): Observable<CommandResult> {
return this.gql
.mutate(AddCommentDocument, { issueId, body })
.mutate(AddCommentDocument, { issueId, body, replyTo })
.pipe(map((d) => toCommandResult(d.addComment)));
}

View File

@@ -89,6 +89,7 @@ export class GraphqlIssuesGateway extends IssuesGateway {
return this.gql
.query(MyIssuesDocument, {
assignedToMe: filter.assignedToMe,
watching: filter.watching ?? false,
categories: filter.categories as never,
limit: filter.limit,
search: filter.search?.trim() || null,

View File

@@ -141,6 +141,8 @@ fragment CommentFields on Comment {
id
issueId
body
replyTo
deleted
createdAt
editedAt
author {

File diff suppressed because one or more lines are too long

View File

@@ -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
}
}
@@ -132,12 +132,14 @@ mutation DeleteComment($commentId: ID!) {
query MyIssues(
$assignedToMe: Boolean!
$watching: Boolean
$categories: [StatusCategory!]
$limit: Int!
$search: String
) {
myIssues(
assignedToMe: $assignedToMe
watching: $watching
categories: $categories
limit: $limit
search: $search

View File

@@ -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;
}

View File

@@ -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;
<article class="ts-block">
<h2 class="ts-section">
Comentarios
<span class="ts-mono">{{ facade.comments().length }}</span>
<span class="ts-mono">{{ commentCount() }}</span>
</h2>
@for (comment of facade.comments(); track comment.id) {
<ng-template #commentView let-comment>
<div class="ts-comment">
<span class="ts-comment__avatar" aria-hidden="true">{{
initialsOf(comment.author.name)
}}</span>
<div class="ts-comment__body">
<div class="ts-comment__head">
<strong>{{ comment.author.name }}</strong>
<strong>{{
comment.deleted ? '—' : comment.author.name
}}</strong>
<span class="ts-comment__when"
>{{ ago(comment.createdAt)
}}{{ comment.editedAt ? ' · editado' : '' }}</span
>
<span class="ts-spacer"></span>
@if (comment.author.id === facade.me()) {
@if (!comment.deleted && comment.author.id === facade.me()) {
<pi-icon-button
icon="edit"
color="grey"
@@ -537,7 +541,8 @@ const MAX_FILE_BYTES = 25 * 1024 * 1024;
/>
}
@if (
comment.author.id === facade.me() || project.canManage()
!comment.deleted &&
(comment.author.id === facade.me() || project.canManage())
) {
<pi-icon-button
icon="trash"
@@ -548,6 +553,9 @@ const MAX_FILE_BYTES = 25 * 1024 * 1024;
/>
}
</div>
@if (comment.deleted) {
<p class="ts-muted ts-comment__gone">Comentario borrado</p>
} @else {
<p class="ts-prewrap">
@for (part of segments(comment.body); track $index) {
@if ('mention' in part) {
@@ -561,7 +569,8 @@ const MAX_FILE_BYTES = 25 * 1024 * 1024;
}
}
</p>
@if (filesOf(comment).length) {
}
@if (!comment.deleted && filesOf(comment).length) {
<div class="ts-attachments">
@for (file of filesOf(comment); track file.id) {
<ts-attachment
@@ -573,6 +582,57 @@ const MAX_FILE_BYTES = 25 * 1024 * 1024;
}
</div>
</div>
</ng-template>
@for (thread of threads(); track thread.comment.id) {
<ng-container
*ngTemplateOutlet="commentView; context: { $implicit: thread.comment }"
/>
<div class="ts-replies">
@for (reply of thread.replies; track reply.id) {
<ng-container
*ngTemplateOutlet="commentView; context: { $implicit: reply }"
/>
}
@if (replyingTo() === thread.comment.id) {
<form
class="ts-form ts-reply"
(submit)="$event.preventDefault(); reply(thread.comment)"
novalidate
>
<pi-textarea
[placeholder]="'Responde a ' + thread.comment.author.name + '…'"
[(value)]="replyBody"
/>
<div class="ts-reply__actions">
<pi-action-button
color="primary"
text="Responder"
[disabled]="!replyBody().trim()"
(execute)="reply(thread.comment)"
/>
<button
type="button"
class="ts-plain-link"
(click)="replyingTo.set(null)"
>
Cancelar
</button>
</div>
</form>
} @else if (project.canContribute() && !thread.comment.deleted) {
<button
type="button"
class="ts-plain-link ts-reply__open"
(click)="startReply(thread.comment)"
>
Responder{{
thread.replies.length
? ' (' + thread.replies.length + ')'
: ''
}}
</button>
}
</div>
}
@if (project.canContribute()) {
<form
@@ -934,6 +994,25 @@ export class IssuePage {
protected readonly linkType = signal<string | null>(IssueLinkType.RELATES_TO);
protected readonly linkTarget = signal('');
protected readonly commentBody = signal('');
/** The comment being answered, and the answer being written. */
protected readonly replyingTo = signal<string | null>(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<string, Comment[]>();
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<File[]>([]);
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));
}

View File

@@ -33,7 +33,8 @@ const VISIBLE = 12;
/**
* "Mi trabajo" on the home page, across every project I belong to: my open
* tickets (QA failures first), tickets waiting for QA and upcoming releases.
* tickets (QA failures first), tickets waiting for QA, the open ones I follow
* and upcoming releases.
* Reloads when the tab becomes visible again.
*/
@Component({
@@ -147,6 +148,39 @@ const VISIBLE = 12;
</ul>
</section>
<section
class="ts-panel ts-work__panel"
aria-labelledby="following-title"
>
<header class="ts-work__head">
<h2 id="following-title">Que sigo</h2>
<span class="ts-muted ts-mono">{{ home.followingTotal() }}</span>
</header>
<ul class="ts-work__list">
@for (issue of home.following().slice(0, 6); track issue.id) {
<li>
<a
class="ts-work__row ts-work__row--qa"
[routerLink]="['/p', keyOf(issue.key), 'issues', issue.key]"
[style.--type]="statusTone(issue)"
>
<span class="ts-key">{{ issue.key }}</span>
<span class="ts-work__title">{{ issue.title }}</span>
@if (issue.assignee; as assignee) {
<span class="ts-work__who" [attr.title]="assignee.name">{{
initialsOf(assignee.name)
}}</span>
}
</a>
</li>
} @empty {
<li class="ts-work__empty">
No sigues ningún ticket abierto. En la ficha de uno, «Seguir».
</li>
}
</ul>
</section>
<section
class="ts-panel ts-work__panel"
aria-labelledby="releases-title"

View File

@@ -27,6 +27,7 @@ const ICON: Record<NotificationKind, string> = {
[NotificationKind.QA_FAILED]: 'exclamation',
[NotificationKind.COMMENTED]: 'chat',
[NotificationKind.MENTIONED]: 'chat',
[NotificationKind.REPLIED]: 'chat',
[NotificationKind.WATCHED]: 'focus',
[NotificationKind.ADDED_TO_PROJECT]: 'briefcase',
[NotificationKind.REMINDER]: 'calendar',