feat: mentions and following tickets

Mention project members in comments with @, follow and unfollow tickets,
and get told when something changes on the tickets you follow. Commenting
on a ticket starts following it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-08 09:41:32 +02:00
parent efa06a0723
commit 88928b402f
49 changed files with 1318 additions and 41 deletions

View File

@@ -1719,6 +1719,207 @@
]
}
},
"/api/issues/{issue}/watch": {
"put": {
"description": "Followers are told when it moves, is reassigned or gets a comment. Commenting follows it too.",
"operationId": "IssuesController_watch",
"parameters": [
{
"name": "issue",
"required": true,
"in": "path",
"description": "Ticket id or key",
"schema": {
"example": "WEB-12",
"type": "string"
}
}
],
"responses": {
"200": {
"description": "",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/CommandResultDto"
}
}
}
},
"400": {
"description": "Invalid input",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiErrorDto"
}
}
}
},
"401": {
"description": "Missing or rejected Pireon token",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiErrorDto"
}
}
}
},
"403": {
"description": "Not a member of the project (or not allowed)",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiErrorDto"
}
}
}
},
"404": {
"description": "Not found",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiErrorDto"
}
}
}
},
"409": {
"description": "Someone changed it at the same time: retry",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiErrorDto"
}
}
}
},
"422": {
"description": "Business rule (e.g. a transition the workflow forbids)",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiErrorDto"
}
}
}
}
},
"security": [
{
"pireon-oauth": []
},
{
"pireon": []
}
],
"summary": "Follow a ticket (members, viewers included)",
"tags": [
"Issues"
]
},
"delete": {
"operationId": "IssuesController_unwatch",
"parameters": [
{
"name": "issue",
"required": true,
"in": "path",
"description": "Ticket id or key",
"schema": {
"example": "WEB-12",
"type": "string"
}
}
],
"responses": {
"200": {
"description": "",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/CommandResultDto"
}
}
}
},
"400": {
"description": "Invalid input",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiErrorDto"
}
}
}
},
"401": {
"description": "Missing or rejected Pireon token",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiErrorDto"
}
}
}
},
"403": {
"description": "Not a member of the project (or not allowed)",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiErrorDto"
}
}
}
},
"404": {
"description": "Not found",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiErrorDto"
}
}
}
},
"409": {
"description": "Someone changed it at the same time: retry",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiErrorDto"
}
}
}
},
"422": {
"description": "Business rule (e.g. a transition the workflow forbids)",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiErrorDto"
}
}
}
}
},
"security": [
{
"pireon-oauth": []
},
{
"pireon": []
}
],
"summary": "Stop following a ticket",
"tags": [
"Issues"
]
}
},
"/api/me/issues": {
"get": {
"description": "Assigned to me by default; with assignedToMe=false, any ticket of my projects (e.g. waiting for QA).",
@@ -6132,6 +6333,13 @@
"$ref": "#/components/schemas/AttachmentDto"
}
},
"watchers": {
"description": "People following it",
"type": "array",
"items": {
"$ref": "#/components/schemas/UserRefDto"
}
},
"commentCount": {
"type": "number"
},
@@ -6170,6 +6378,7 @@
"dueDate",
"links",
"attachments",
"watchers",
"commentCount",
"createdAt",
"updatedAt",

View File

@@ -220,6 +220,9 @@ type Issue {
type: String!
updatedAt: String!
version: Int!
"""People following the ticket"""
watchers: [UserRef!]!
}
type IssueChange {
@@ -359,6 +362,9 @@ type Mutation {
"""Put a ticket in a release (null removes it)"""
setIssueRelease(issueId: ID!, releaseId: ID): CommandResult!
unlinkIssues(input: LinkIssuesInput!): CommandResult!
"""Stop following a ticket"""
unwatchIssue(issueId: ID!): CommandResult!
updateIssue(input: UpdateIssueInput!): CommandResult!
updateNotificationSettings(email: Boolean!): NotificationSettings!
updateProject(input: UpdateProjectInput!): CommandResult!
@@ -368,6 +374,11 @@ type Mutation {
"""
updateProjectSettings(projectId: ID!, settings: ProjectSettingsInput!): CommandResult!
updateRelease(input: UpdateReleaseInput!): CommandResult!
"""
Follow a ticket: be told when it moves, is reassigned or gets a comment
"""
watchIssue(issueId: ID!): CommandResult!
}
type MyNotifications {

View File

@@ -301,6 +301,27 @@ export interface paths {
patch: operations["ProjectsController_changeMemberRole"];
trace?: never;
};
"/api/issues/{issue}/watch": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
/**
* Follow a ticket (members, viewers included)
* @description Followers are told when it moves, is reassigned or gets a comment. Commenting follows it too.
*/
put: operations["IssuesController_watch"];
post?: never;
/** Stop following a ticket */
delete: operations["IssuesController_unwatch"];
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/me/issues": {
parameters: {
query?: never;
@@ -1093,6 +1114,8 @@ export interface components {
dueDate: string | null;
links: components["schemas"]["IssueLinkDto"][];
attachments: components["schemas"]["AttachmentDto"][];
/** @description People following it */
watchers: components["schemas"]["UserRefDto"][];
commentCount: number;
/** Format: date-time */
createdAt: string;
@@ -2632,6 +2655,158 @@ export interface operations {
};
};
};
IssuesController_watch: {
parameters: {
query?: never;
header?: never;
path: {
/** @description Ticket id or key */
issue: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["CommandResultDto"];
};
};
/** @description Invalid input */
400: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ApiErrorDto"];
};
};
/** @description Missing or rejected Pireon token */
401: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ApiErrorDto"];
};
};
/** @description Not a member of the project (or not allowed) */
403: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ApiErrorDto"];
};
};
/** @description Not found */
404: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ApiErrorDto"];
};
};
/** @description Someone changed it at the same time: retry */
409: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ApiErrorDto"];
};
};
/** @description Business rule (e.g. a transition the workflow forbids) */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ApiErrorDto"];
};
};
};
};
IssuesController_unwatch: {
parameters: {
query?: never;
header?: never;
path: {
/** @description Ticket id or key */
issue: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["CommandResultDto"];
};
};
/** @description Invalid input */
400: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ApiErrorDto"];
};
};
/** @description Missing or rejected Pireon token */
401: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ApiErrorDto"];
};
};
/** @description Not a member of the project (or not allowed) */
403: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ApiErrorDto"];
};
};
/** @description Not found */
404: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ApiErrorDto"];
};
};
/** @description Someone changed it at the same time: retry */
409: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ApiErrorDto"];
};
};
/** @description Business rule (e.g. a transition the workflow forbids) */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ApiErrorDto"];
};
};
};
};
IssuesController_mine: {
parameters: {
query?: {

View File

@@ -13,7 +13,8 @@
| 🧪 QA | Por defecto, `READY_FOR_QA → IN_QA → DONE / QA_FAILED`; fallar QA exige un motivo |
| 🚀 Releases | Versiones a las que se asignan tickets; no se publican con tickets sin terminar (salvo forzando) |
| 👥 Asignaciones | Solo a miembros del proyecto; panel de carga por persona |
| 💬 Comentarios | Decisiones y explicaciones dentro del ticket; el autor edita, el admin modera |
| 💬 Comentarios | Decisiones y explicaciones dentro del ticket, con `@menciones`; el autor edita, el admin modera |
| 👀 Seguir | Cualquier miembro sigue los tickets que le interesan; quien comenta, los sigue solo |
| 🔔 Avisos | Campana con lo que te concierne (asignaciones, cambios de estado, QA fallido, comentarios), en vivo y opcionalmente por correo |
| 📎 Adjuntos | Capturas, logs y vídeos en el ticket o bajo un comentario; arrastrar, elegir o pegar con Ctrl+V; miniaturas de las imágenes |
| 🔗 Relaciones | `BLOCKS`, `DEPENDS_ON`, `TESTS`, `DUPLICATES`, `RELATES_TO`, también entre proyectos (FE ↔ BE ↔ QA) |

View File

@@ -138,10 +138,11 @@ evento, tras las proyecciones, pasa ese evento solo (`of(event)`) por cada saga
uno y esperando. Así heredan las garantías del bridge: orden, reintento y nada se pierde si la API se cae a mitad.
| Saga | Evento | Comandos |
| --------------------- | ------------------------------------------ | ----------------------------------------------------------------------------------------------------------- |
| ---------------------- | ------------------------------------------ | ----------------------------------------------------------------------------------------------------------- |
| `ProjectDeletionSaga` | `ProjectDeletedEvent` | `DeleteIssueCommand` por cada ticket y `DeleteReleaseCommand` por cada release del proyecto |
| | `IssueDeletedEvent` | `PurgeCommentCommand` por cada comentario y `DropLinksToIssueCommand` en los tickets que lo enlazaban |
| `ReleaseArchivedSaga` | `ReleaseArchivedEvent` | `DetachIssueFromReleaseCommand` para sus tickets sin terminar (los terminados se quedan) |
| `CommentersFollowSaga` | `CommentAddedEvent` | `WatchIssueCommand`: quien comenta pasa a seguir el ticket |
| `UserDeactivatedSaga` | `UserDeactivatedEvent` (contexto identity) | `UnassignIssueCommand` para sus tickets abiertos en todos los proyectos, como actor `system` («TaskStream») |
Reglas que cumplen:

View File

@@ -162,7 +162,13 @@ ningún agregado, solo cada uno lee y descarta las suyas.
| Alguien comenta | Quien lo tiene asignado y quien lo abrió | `COMMENTED` |
| Te añaden a un proyecto | La persona añadida | `ADDED_TO_PROJECT` |
Nadie recibe aviso de lo que hizo él mismo, y cada persona recibe uno por evento (la primera razón gana). El id es
Nadie recibe aviso de lo que hizo él mismo, y cada persona recibe uno por evento: la primera razón de la tabla gana
(una mención antes que «comentó en tu ticket», y eso antes que «un ticket que sigues»).
**Menciones**: se escriben `@[Nombre](id-de-usuario)` (la web las inserta al escribir `@`); el id dice a quién avisar y
el nombre es lo que se ve, así que renombrar a alguien no rompe comentarios antiguos. Solo avisan a miembros del
proyecto. **Seguir**: cualquier miembro, también los lectores, puede seguir un ticket (`watchIssue`/`unwatchIssue`,
eventos `IssueWatchedEvent`/`IssueUnwatchedEvent`), y quien comenta pasa a seguirlo (`CommentersFollowSaga`). El id es
`<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.
@@ -431,10 +437,10 @@ Lo que cada usuario puede ver o cambiar depende de su rol en cada proyecto, como
Proyectos y tickets se pueden indicar por **id o por clave** (`WEB`, `WEB-12`).
| Recurso | Rutas |
| ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| ----------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Usuarios | `GET /api/users/me`, `GET /api/users?search=`, `GET /api/users/{id}` |
| Proyectos | `GET/POST /api/projects`, `GET/PATCH/DELETE /api/projects/{project}` (`DELETE …?confirm=CLAVE`), `PUT …/settings`, `…/board`, `…/stats`, `…/analytics?days=`, `…/activity`, `…/members` (POST, PATCH, DELETE) |
| Tickets | `GET /api/me/issues` (mi trabajo), `GET/POST /api/projects/{project}/issues`, `GET/PATCH /api/issues/{issue}`, `PUT …/status`, `PUT …/assignee`, `PUT …/release`, `POST/DELETE …/links`, `GET/POST …/comments`, `GET …/history` |
| Tickets | `GET /api/me/issues` (mi trabajo), `GET/POST /api/projects/{project}/issues`, `GET/PATCH /api/issues/{issue}`, `PUT …/status`, `PUT …/assignee`, `PUT …/release`, `PUT/DELETE …/watch`, `POST/DELETE …/links`, `GET/POST …/comments`, `GET …/history` |
| Comentarios | `PATCH/DELETE /api/comments/{commentId}` |
| Avisos | `GET /api/me/notifications`, `POST /api/me/notifications/read`, `GET/PUT /api/me/notification-settings` |
| Adjuntos | `POST /api/issues/{issue}/attachments` (multipart: `file` y opcional `commentId`), `GET/DELETE /api/issues/{issue}/attachments/{id}` |

View File

@@ -27,6 +27,7 @@ import { DetachIssueFromReleaseCommandHandler } from './detach-issue-from-releas
import { UnassignIssueCommandHandler } from './unassign-issue.command-handler';
import { AttachFileCommandHandler } from './attach-file.command-handler';
import { DetachFileCommandHandler } from './detach-file.command-handler';
import { WatchIssueCommandHandler } from './watch-issue.command-handler';
export const COMMAND_HANDLERS = [
CreateProjectCommandHandler,
@@ -59,4 +60,5 @@ export const COMMAND_HANDLERS = [
UnassignIssueCommandHandler,
AttachFileCommandHandler,
DetachFileCommandHandler,
WatchIssueCommandHandler,
];

View File

@@ -40,6 +40,7 @@ import { AddProjectMemberCommand } from './add-project-member.command';
import { AssignIssueCommand } from './assign-issue.command';
import { AttachFileCommand } from './attach-file.command';
import { DetachFileCommand } from './detach-file.command';
import { WatchIssueCommand } from './watch-issue.command';
import { ChangeIssueStatusCommand } from './change-issue-status.command';
import { COMMAND_HANDLERS } from './command-handlers';
import { CommandResult } from './command-result';
@@ -258,6 +259,28 @@ describe('tracker commands (event sourced)', () => {
});
});
it('lets any member, viewers included, follow a ticket for themselves', async () => {
const { run, rehydrator } = await setup();
const projectId = await projectWithTeam(run);
const issue = await run(
new CreateIssueCommand('qa', projectId, IssueType.BUG, 'Crash', {}),
);
await run(new WatchIssueCommand('pm', issue.id, 'pm'));
await run(new WatchIssueCommand('dev', issue.id, 'dev'));
await run(new WatchIssueCommand('dev', issue.id, 'dev', false));
expect((await rehydrator.rehydrate(issue.id, Issue)).watchers).toEqual([
'pm',
]);
await expect(
run(new WatchIssueCommand('dev', issue.id, 'pm', false)),
).rejects.toThrow(ForbiddenError);
await expect(
run(new WatchIssueCommand('gone', issue.id, 'gone')),
).rejects.toThrow(ForbiddenError);
});
it('does not let viewers modify tickets', async () => {
const { run } = await setup();
const projectId = await projectWithTeam(run);

View File

@@ -28,3 +28,4 @@ export * from './detach-issue-from-release.command';
export * from './unassign-issue.command';
export * from './attach-file.command';
export * from './detach-file.command';
export * from './watch-issue.command';

View File

@@ -0,0 +1,33 @@
import { CommandHandler, ICommandHandler } from '@nestjs/cqrs';
import { ForbiddenError } from '@task-stream/api/shared/domain';
import { CommandResult, ack } from './command-result';
import { WatchIssueCommand } from './watch-issue.command';
import { WriteAccess } from './write-access';
/**
* Following is for members, viewers included: reading a ticket is enough to
* want to hear about it. People only follow or unfollow for themselves.
*/
@CommandHandler(WatchIssueCommand)
export class WatchIssueCommandHandler
implements ICommandHandler<WatchIssueCommand>
{
constructor(private readonly access: WriteAccess) {}
async execute(command: WatchIssueCommand): Promise<CommandResult> {
const issue = await this.access.issue(command.issueId);
const project = await this.access.project(issue.projectId);
if (command.actorId !== command.userId) {
throw new ForbiddenError('You can only follow tickets yourself');
}
if (!project.isMember(command.userId)) {
throw new ForbiddenError(
`You are not a member of project ${project.key}`,
);
}
if (command.watching) issue.watch(command.actorId, command.userId);
else issue.unwatch(command.actorId, command.userId);
await issue.commitAndWait();
return ack(issue);
}
}

View File

@@ -0,0 +1,14 @@
import { Command } from '@nestjs/cqrs';
import { CommandResult } from './command-result';
/** `userId` starts (`watching`) or stops following a ticket. */
export class WatchIssueCommand extends Command<CommandResult> {
constructor(
public readonly actorId: string,
public readonly issueId: string,
public readonly userId: string,
public readonly watching = true,
) {
super();
}
}

View File

@@ -27,6 +27,7 @@ import { ReleaseDeletedEventHandler } from './release-deleted.event-handler';
import { IssueAttachmentAddedEventHandler } from './issue-attachment-added.event-handler';
import { IssueAttachmentRemovedEventHandler } from './issue-attachment-removed.event-handler';
import { AttachmentCleanupEventHandler } from './attachment-cleanup.event-handler';
import { IssueWatchersEventHandler } from './issue-watchers.event-handler';
/** Read-model projections (MongoDB) + realtime, fed by the EventsBridge. */
export const EVENT_HANDLERS = [
@@ -58,6 +59,7 @@ export const EVENT_HANDLERS = [
IssueAttachmentAddedEventHandler,
IssueAttachmentRemovedEventHandler,
AttachmentCleanupEventHandler,
IssueWatchersEventHandler,
];
export { ReadModelNotifier };

View File

@@ -0,0 +1,40 @@
import { EventsHandler, IEventHandler } from '@nestjs/cqrs';
import {
IssueUnwatchedEvent,
IssueWatchedEvent,
} from '@task-stream/api/tracker/domain';
import { UpsertMaterializedIssueRepository } from '../ports/upsert-materialized-issue.repository';
import { UserDirectory } from '../ports/user-directory';
import { ReadModelNotifier, versionOf } from './read-model-notifier';
@EventsHandler(IssueWatchedEvent, IssueUnwatchedEvent)
export class IssueWatchersEventHandler
implements IEventHandler<IssueWatchedEvent | IssueUnwatchedEvent>
{
constructor(
private readonly upsertMaterializedIssueRepository: UpsertMaterializedIssueRepository,
private readonly users: UserDirectory,
private readonly notifier: ReadModelNotifier,
) {}
async handle(event: IssueWatchedEvent | IssueUnwatchedEvent) {
// Following is not a change to the ticket: `updatedAt` stays.
await this.upsertMaterializedIssueRepository.upsert({
id: event.aggregateId,
version: versionOf(event),
});
if (event instanceof IssueWatchedEvent) {
const users = await this.users.refs([event.userId]);
await this.upsertMaterializedIssueRepository.addWatcher(
event.aggregateId,
users.of(event.userId),
);
} else {
await this.upsertMaterializedIssueRepository.removeWatcher(
event.aggregateId,
event.userId,
);
}
await this.notifier.issue(event.aggregateId, event);
}
}

View File

@@ -12,6 +12,7 @@ import {
IncomingFile,
} from './commands/attach-file.command';
import { DetachFileCommand } from './commands/detach-file.command';
import { WatchIssueCommand } from './commands/watch-issue.command';
import { ChangeIssueStatusCommand } from './commands/change-issue-status.command';
import { CommandResult } from './commands/command-result';
import { CreateIssueCommand } from './commands/create-issue.command';
@@ -157,6 +158,17 @@ export class IssuesService {
);
}
/** The actor follows (or stops following) the ticket. */
watch(
actorId: string,
issueId: string,
watching: boolean,
): Promise<CommandResult> {
return this.commandBus.execute(
new WatchIssueCommand(actorId, issueId, actorId, watching),
);
}
/** A ticket's file, with its bytes, for whoever can read the ticket. */
download(
viewerId: string | null,

View File

@@ -4,10 +4,12 @@ import {
IssueAssignedEvent,
IssueCreatedEvent,
IssueStatusChangedEvent,
mentionedUserIds,
NotificationKind,
ProjectMemberAddedEvent,
StatusCategory,
TrackerEvent,
withoutMentionMarkup,
} from '@task-stream/api/tracker/domain';
import { FindIssuesRepository } from '../ports/find-issues.repository';
import { FindProjectsRepository } from '../ports/find-projects.repository';
@@ -17,14 +19,16 @@ import { NotificationDraft, Notifier } from './notifier';
/* Read by people, like the activity feed: Spanish. */
const excerpt = (text: string, max = 120) => {
const flat = text.replace(/\s+/g, ' ').trim();
const flat = withoutMentionMarkup(text).replace(/\s+/g, ' ').trim();
return flat.length > max ? `${flat.slice(0, max - 1)}…` : flat;
};
/**
* Who is told what, for the events that concern someone in particular:
* whoever a ticket is assigned to, whoever reported or works on it when it
* moves or gets a comment, whoever joins a project.
* whoever is mentioned, whoever a ticket is assigned to, whoever reported or
* works on it when it moves or gets a comment, whoever follows it, whoever
* joins a project. The order is the order of importance: each person gets
* one notification per event, for the first reason that applies.
*/
@EventsHandler(
IssueCreatedEvent,
@@ -81,6 +85,9 @@ export class NotificationsEventHandler implements IEventHandler<TrackerEvent> {
const assigneeId =
'assigneeId' in issue ? issue.assigneeId : (issue.assignee?.id ?? null);
const ticket = `${issue.key}: ${issue.title}`;
const watcherIds =
'watchers' in issue ? issue.watchers.map((w) => w.id) : [];
const memberIds = new Set(project.members.map((m) => m.user.id));
const drafts: NotificationDraft[] = [];
if (event instanceof IssueCreatedEvent && assigneeId) {
@@ -90,13 +97,27 @@ export class NotificationsEventHandler implements IEventHandler<TrackerEvent> {
summary: `${actor} te asignó ${ticket}`,
});
}
if (event instanceof IssueAssignedEvent && event.assigneeId) {
if (event instanceof IssueAssignedEvent) {
const assignee = event.assigneeId
? (await this.users.refs([event.assigneeId])).of(event.assigneeId).name
: null;
if (event.assigneeId) {
drafts.push({
userId: event.assigneeId,
kind: NotificationKind.ASSIGNED,
summary: `${actor} te asignó ${ticket}`,
});
}
for (const userId of watcherIds) {
drafts.push({
userId,
kind: NotificationKind.WATCHED,
summary: assignee
? `${actor} asignó ${ticket} a ${assignee}`
: `${actor} dejó sin asignar ${ticket}`,
});
}
}
if (event instanceof IssueStatusChangedEvent) {
const to = event.meta?.toName ?? event.to;
// Back from QA without passing: the one move whoever did the work must hear about.
@@ -105,6 +126,9 @@ export class NotificationsEventHandler implements IEventHandler<TrackerEvent> {
event.meta?.toCategory !== StatusCategory.QA &&
event.meta?.toCategory !== StatusCategory.DONE;
const reason = event.reason ? `: ${excerpt(event.reason)}` : '';
const summary = failedQa
? `${actor} devolvió ${ticket} desde QA${reason}`
: `${actor} movió ${ticket} a ${to}${reason}`;
for (const userId of [assigneeId, reporterId]) {
if (!userId) continue;
drafts.push({
@@ -112,19 +136,37 @@ export class NotificationsEventHandler implements IEventHandler<TrackerEvent> {
kind: failedQa
? NotificationKind.QA_FAILED
: NotificationKind.STATUS_CHANGED,
summary: failedQa
? `${actor} devolvió ${ticket} desde QA${reason}`
: `${actor} movió ${ticket} a ${to}${reason}`,
summary,
});
}
for (const userId of watcherIds) {
drafts.push({ userId, kind: NotificationKind.WATCHED, summary });
}
}
if (event instanceof CommentAddedEvent) {
const said = `«${excerpt(event.comment.body)}»`;
// Only members: a mention cannot reach someone who cannot read the ticket.
for (const userId of mentionedUserIds(event.comment.body)) {
if (!memberIds.has(userId)) continue;
drafts.push({
userId,
kind: NotificationKind.MENTIONED,
summary: `${actor} te mencionó en ${issue.key}: ${said}`,
});
}
for (const userId of [assigneeId, reporterId]) {
if (!userId) continue;
drafts.push({
userId,
kind: NotificationKind.COMMENTED,
summary: `${actor} comentó en ${issue.key}: «${excerpt(event.comment.body)}»`,
summary: `${actor} comentó en ${issue.key}: ${said}`,
});
}
for (const userId of watcherIds) {
drafts.push({
userId,
kind: NotificationKind.WATCHED,
summary: `${actor} comentó en ${issue.key}, que sigues: ${said}`,
});
}
}

View File

@@ -54,13 +54,21 @@ function setup(mailEnabled = false) {
const repo = new InMemoryNotifications();
const published: NotificationReadModel[] = [];
const mailed: string[] = [];
const users = new FakeUserDirectory().add('ana').add('bob').add('qa');
const users = new FakeUserDirectory()
.add('ana')
.add('bob')
.add('qa')
.add('pm');
const issue = {
id: 'i-1',
key: 'WEB-1',
title: 'Login roto',
reporter: { id: 'qa', name: 'QA' },
assignee: { id: 'bob', name: 'Bob' },
watchers: [
{ id: 'pm', name: 'PM' },
{ id: 'bob', name: 'Bob' },
],
};
const notifier = new Notifier(
repo,
@@ -81,7 +89,15 @@ function setup(mailEnabled = false) {
notifier,
{ findById: async () => issue } as unknown as FindIssuesRepository,
{
findById: async () => ({ id: 'p-1', key: 'WEB', name: 'Web' }),
findById: async () => ({
id: 'p-1',
key: 'WEB',
name: 'Web',
members: ['ana', 'bob', 'qa', 'pm'].map((id) => ({
user: { id, name: id },
role: ProjectRole.MEMBER,
})),
}),
} as unknown as FindProjectsRepository,
users,
);
@@ -100,8 +116,12 @@ describe('notifications', () => {
expect(repo.all.map((n) => [n.userId, n.kind])).toEqual([
['bob', NotificationKind.ASSIGNED],
// Bob follows the ticket too, but is told once: as the assignee.
['pm', NotificationKind.WATCHED],
['pm', NotificationKind.WATCHED],
['bob', NotificationKind.WATCHED],
]);
expect(published).toHaveLength(1);
expect(published).toHaveLength(4);
expect(repo.all[0].summary).toContain('WEB-1');
});
@@ -129,6 +149,7 @@ describe('notifications', () => {
expect(repo.all.map((n) => [n.userId, n.kind])).toEqual([
['bob', NotificationKind.QA_FAILED],
['qa', NotificationKind.QA_FAILED],
['pm', NotificationKind.WATCHED],
]);
expect(repo.all[0].summary).toContain('Falla en Safari');
});
@@ -151,7 +172,32 @@ describe('notifications', () => {
await handler.handle(comment);
await handler.handle(comment);
expect(repo.all.map((n) => n.userId)).toEqual(['qa']);
expect(repo.all.map((n) => n.userId)).toEqual(['qa', 'pm']);
});
it('tells a mentioned member first, and nobody outside the project', async () => {
const { handler, repo } = setup();
await handler.handle(
at(
new CommentAddedEvent('bob', {
id: 'c-2',
issueId: 'i-1',
projectId: 'p-1',
authorId: 'bob',
body: '@[PM](pm) mira esto, y @[Fuera](stranger) también',
deleted: false,
createdAt: '2026-10-08T10:00:00.000Z',
editedAt: null,
}),
1,
),
);
expect(repo.all.map((n) => [n.userId, n.kind])).toEqual([
['pm', NotificationKind.MENTIONED],
['qa', NotificationKind.COMMENTED],
]);
expect(repo.all[0].summary).toContain('«@PM mira esto');
});
it('copies by e-mail only to whoever asked for it, when mail is configured', async () => {

View File

@@ -3,13 +3,19 @@ import {
IssueLinkReadModel,
IssueReadModel,
StatusCategory,
UserRefReadModel,
} from '@task-stream/api/tracker/domain';
export abstract class UpsertMaterializedIssueRepository {
/** Applied only if `version` is newer than the stored one (idempotent). */
abstract upsert(
issue: Pick<IssueReadModel, 'id' | 'version'> &
Partial<Omit<IssueReadModel, 'links' | 'commentCount' | 'attachments'>>,
Partial<
Omit<
IssueReadModel,
'links' | 'commentCount' | 'attachments' | 'watchers'
>
>,
): Promise<void>;
/** Adds a relation to an issue (idempotent). */
abstract addLink(issueId: string, link: IssueLinkReadModel): Promise<void>;
@@ -26,6 +32,9 @@ export abstract class UpsertMaterializedIssueRepository {
issueId: string,
attachmentId: string,
): Promise<void>;
/** Adds a follower (idempotent). */
abstract addWatcher(issueId: string, user: UserRefReadModel): Promise<void>;
abstract removeWatcher(issueId: string, userId: string): Promise<void>;
abstract setCommentCount(
issueId: string,
commentCount: number,

View File

@@ -0,0 +1,25 @@
import { CommentAddedEvent } from '@task-stream/api/tracker/domain';
import { lastValueFrom, of, toArray } from 'rxjs';
import { WatchIssueCommand } from '../commands/watch-issue.command';
import { CommentersFollowSaga } from './commenters-follow.saga';
describe('CommentersFollowSaga', () => {
it('makes whoever comments follow the ticket', async () => {
const event = new CommentAddedEvent('ana', {
id: 'c-1',
issueId: 'i-1',
projectId: 'p-1',
authorId: 'ana',
body: 'Lo miro',
deleted: false,
createdAt: '2026-10-08T10:00:00.000Z',
editedAt: null,
});
const commands = await lastValueFrom(
new CommentersFollowSaga().commented(of(event)).pipe(toArray()),
);
expect(commands).toEqual([new WatchIssueCommand('ana', 'i-1', 'ana')]);
});
});

View File

@@ -0,0 +1,27 @@
import { Injectable } from '@nestjs/common';
import { ICommand, IEvent, ofType, Saga } from '@nestjs/cqrs';
import { CommentAddedEvent } from '@task-stream/api/tracker/domain';
import { map, Observable } from 'rxjs';
import { WatchIssueCommand } from '../commands/watch-issue.command';
/**
* Whoever comments on a ticket follows it from then on: they took part in the
* conversation, so they will want to hear how it ends. Unfollowing stays
* theirs to do. (Idempotent: following twice changes nothing.)
*/
@Injectable()
export class CommentersFollowSaga {
@Saga()
commented = (events$: Observable<IEvent>): Observable<ICommand> =>
events$.pipe(
ofType(CommentAddedEvent),
map(
(event) =>
new WatchIssueCommand(
event.comment.authorId,
event.comment.issueId,
event.comment.authorId,
),
),
);
}

View File

@@ -1,10 +1,12 @@
import { ProjectDeletionSaga } from './project-deletion.saga';
import { ReleaseArchivedSaga } from './release-archived.saga';
import { UserDeactivatedSaga } from './user-deactivated.saga';
import { CommentersFollowSaga } from './commenters-follow.saga';
/** Reactions to events that change other aggregates (run by the events bridge). */
export const SAGAS = [
ProjectDeletionSaga,
ReleaseArchivedSaga,
UserDeactivatedSaga,
CommentersFollowSaga,
];

View File

@@ -3,6 +3,7 @@ export * from './lib/project/project-role';
export * from './lib/issue/issue';
export * from './lib/issue/issue.enums';
export * from './lib/issue/issue-attachment';
export * from './lib/issue/mentions';
export * from './lib/project/project-settings';
export * from './lib/comment/comment';
export * from './lib/release/release';

View File

@@ -24,3 +24,5 @@ export * from './issue-deleted.event';
export * from './release-deleted.event';
export * from './issue-attachment-added.event';
export * from './issue-attachment-removed.event';
export * from './issue-watched.event';
export * from './issue-unwatched.event';

View File

@@ -0,0 +1,15 @@
import { AutowiredEvent } from '@task-stream/api/shared/domain';
import { TrackerEvent } from './tracker.event';
/** Someone stopped following a ticket. */
@AutowiredEvent
export class IssueUnwatchedEvent extends TrackerEvent {
constructor(
actorId: string,
issueId: string,
projectId: string,
readonly userId: string,
) {
super(actorId, issueId, projectId);
}
}

View File

@@ -0,0 +1,15 @@
import { AutowiredEvent } from '@task-stream/api/shared/domain';
import { TrackerEvent } from './tracker.event';
/** Someone started following a ticket. */
@AutowiredEvent
export class IssueWatchedEvent extends TrackerEvent {
constructor(
actorId: string,
issueId: string,
projectId: string,
readonly userId: string,
) {
super(actorId, issueId, projectId);
}
}

View File

@@ -8,6 +8,7 @@ import { IssueStatusChangedEvent } from '../events/issue-status-changed.event';
import { IssueFactory } from '../factories/issue.factory';
import { replay } from '../testing';
import { Issue } from './issue';
import { mentionedUserIds, withoutMentionMarkup } from './mentions';
import { IssueLinkType, IssueStatus, IssueType } from './issue.enums';
const factory = new IssueFactory();
@@ -206,4 +207,31 @@ describe('Issue', () => {
expect(rebuilt.attachments.map((a) => a.id)).toEqual(['a-2']);
});
});
describe('followers', () => {
it('follows and unfollows once, and survives a replay', () => {
const issue = newIssue();
issue.watch('u-dev', 'u-dev');
issue.watch('u-dev', 'u-dev');
issue.watch('u-pm', 'u-pm');
issue.unwatch('u-dev', 'u-dev');
issue.unwatch('u-dev', 'u-dev');
expect(issue.watchers).toEqual(['u-pm']);
expect(replay(issue, Issue).rebuilt.watchers).toEqual(['u-pm']);
expect(issue.getUncommittedEvents()).toHaveLength(4);
});
});
describe('mentions', () => {
it('reads who a text mentions, once each', () => {
const text =
'Hola @[Ana García](u-ana) y @[Bob](u-bob), @[Ana García](u-ana) otra vez. @sinmarca';
expect(mentionedUserIds(text)).toEqual(['u-ana', 'u-bob']);
expect(withoutMentionMarkup(text)).toBe(
'Hola @Ana García y @Bob, @Ana García otra vez. @sinmarca',
);
});
});
});

View File

@@ -14,6 +14,8 @@ import { IssueReleaseChangedEvent } from '../events/issue-release-changed.event'
import { IssueStatusChangedEvent } from '../events/issue-status-changed.event';
import { IssueUnlinkedEvent } from '../events/issue-unlinked.event';
import { IssueUpdatedEvent } from '../events/issue-updated.event';
import { IssueUnwatchedEvent } from '../events/issue-unwatched.event';
import { IssueWatchedEvent } from '../events/issue-watched.event';
import { ProjectWorkflow } from '../project/project-settings';
import {
IssueAttachment,
@@ -91,6 +93,8 @@ export class Issue extends VersionedAggregateRoot implements IssueProps {
public dueDate: string | null = null;
public links: IssueLink[] = [];
public attachments: IssueAttachment[] = [];
/** People who asked to be told about this ticket. */
public watchers: string[] = [];
public createdAt!: string;
public updatedAt!: string;
public deleted = false;
@@ -262,6 +266,20 @@ export class Issue extends VersionedAggregateRoot implements IssueProps {
);
}
/** `userId` starts following the ticket. Idempotent. */
watch(actorId: string, userId: string): void {
if (this.watchers.includes(userId)) return;
this.apply(new IssueWatchedEvent(actorId, this.id, this.projectId, userId));
}
/** `userId` stops following the ticket. Idempotent. */
unwatch(actorId: string, userId: string): void {
if (!this.watchers.includes(userId)) return;
this.apply(
new IssueUnwatchedEvent(actorId, this.id, this.projectId, userId),
);
}
/** Deletes the ticket (its project was deleted). Idempotent. */
delete(actorId: string): void {
if (this.deleted) return;
@@ -334,6 +352,14 @@ export class Issue extends VersionedAggregateRoot implements IssueProps {
this.updatedAt = event.occurredAt;
}
onIssueWatchedEvent(event: IssueWatchedEvent): void {
this.watchers = [...this.watchers, event.userId];
}
onIssueUnwatchedEvent(event: IssueUnwatchedEvent): void {
this.watchers = this.watchers.filter((id) => id !== event.userId);
}
onIssueDeletedEvent(): void {
this.deleted = true;
}

View File

@@ -0,0 +1,17 @@
/**
* Mentions are written `@[Ana García](<user id>)`: the name to show and who
* it is, so renaming someone never breaks an old comment. The web app inserts
* them from a list of the project's members.
*/
const MENTION = /@\[([^\]\n]{1,80})\]\(([\w-]{1,64})\)/g;
/** Who a text mentions, once each, in order. */
export function mentionedUserIds(text: string | null | undefined): string[] {
if (!text) return [];
return [...new Set([...text.matchAll(MENTION)].map((m) => m[2]))];
}
/** The text as a person reads it: `@Ana García` instead of the markup. */
export function withoutMentionMarkup(text: string): string {
return text.replace(MENTION, (_, name: string) => `@${name}`);
}

View File

@@ -47,6 +47,8 @@ export class IssueReadModel {
dueDate!: string | null;
links!: IssueLinkReadModel[];
attachments!: AttachmentReadModel[];
/** People following the ticket. */
watchers!: UserRefReadModel[];
commentCount!: number;
createdAt!: string;
updatedAt!: string;

View File

@@ -77,6 +77,7 @@ export const IssueSchema = new Schema<IssueDoc>(
release: { _id: false, id: String, name: String },
outwardLinks: [link],
inwardLinks: [link],
watchers: [userRef],
attachments: [
{
_id: false,
@@ -97,6 +98,7 @@ export const IssueSchema = new Schema<IssueDoc>(
IssueSchema.index({ projectId: 1, status: 1, updatedAt: -1 });
IssueSchema.index({ projectId: 1, statusCategory: 1 });
IssueSchema.index({ 'assignee.id': 1 });
IssueSchema.index({ 'watchers.id': 1 });
IssueSchema.index({ 'release.id': 1 });
IssueSchema.index({ title: 'text', description: 'text', key: 'text' });
@@ -179,5 +181,6 @@ export function issueFromDoc(doc: IssueDoc): IssueReadModel {
links: [...(outwardLinks ?? []), ...(inwardLinks ?? [])],
// Tickets projected before attachments existed have none.
attachments: rest.attachments ?? [],
watchers: rest.watchers ?? [],
};
}

View File

@@ -18,6 +18,7 @@ import {
ProjectReadModel,
ReleaseReadModel,
StatusCategory,
UserRefReadModel,
} from '@task-stream/api/tracker/domain';
import { Model } from 'mongoose';
import {
@@ -100,15 +101,35 @@ export class MongoUpsertMaterializedIssueRepository extends UpsertMaterializedIs
version,
...fields
}: Pick<IssueReadModel, 'id' | 'version'> &
Partial<Omit<IssueReadModel, 'links' | 'commentCount' | 'attachments'>>) {
Partial<
Omit<
IssueReadModel,
'links' | 'commentCount' | 'attachments' | 'watchers'
>
>) {
await upsertIfNewer(this.issues, id, version, fields, {
outwardLinks: [],
inwardLinks: [],
attachments: [],
watchers: [],
commentCount: 0,
});
}
async addWatcher(issueId: string, user: UserRefReadModel) {
await this.issues.updateOne(
{ _id: issueId, 'watchers.id': { $ne: user.id } },
{ $push: { watchers: user } },
);
}
async removeWatcher(issueId: string, userId: string) {
await this.issues.updateOne(
{ _id: issueId },
{ $pull: { watchers: { id: userId } } },
);
}
async addAttachment(issueId: string, attachment: AttachmentReadModel) {
await this.issues.updateOne(
{ _id: issueId, 'attachments.id': { $ne: attachment.id } },

View File

@@ -183,6 +183,25 @@ export class IssueResolver {
);
}
@Mutation(() => CommandResultGql, {
description:
'Follow a ticket: be told when it moves, is reassigned or gets a comment',
})
watchIssue(
@CurrentUser() me: User,
@Args('issueId', { type: () => ID }) issueId: string,
) {
return this.issuesService.watch(me.id, issueId, true);
}
@Mutation(() => CommandResultGql, { description: 'Stop following a ticket' })
unwatchIssue(
@CurrentUser() me: User,
@Args('issueId', { type: () => ID }) issueId: string,
) {
return this.issuesService.watch(me.id, issueId, false);
}
@Mutation(() => CommandResultGql, {
description:
'Remove a file from a ticket (whoever uploaded it, or an owner/admin). Upload with the REST API.',

View File

@@ -143,6 +143,8 @@ export class IssueGql {
@Field(() => String, { nullable: true }) dueDate!: string | null;
@Field(() => [IssueLinkGql]) links!: IssueLinkGql[];
@Field(() => [AttachmentGql]) attachments!: AttachmentGql[];
@Field(() => [UserRefGql], { description: 'People following the ticket' })
watchers!: UserRefGql[];
@Field(() => Int) commentCount!: number;
@Field() createdAt!: string;
@Field() updatedAt!: string;

View File

@@ -159,6 +159,8 @@ export class IssueDto {
dueDate!: string | null;
@ApiProperty({ type: [IssueLinkDto] }) links!: IssueLinkDto[];
@ApiProperty({ type: [AttachmentDto] }) attachments!: AttachmentDto[];
@ApiProperty({ type: [UserRefDto], description: 'People following it' })
watchers!: UserRefDto[];
@ApiProperty() commentCount!: number;
@ApiProperty({ format: 'date-time' }) createdAt!: string;
@ApiProperty({ format: 'date-time' }) updatedAt!: string;

View File

@@ -77,6 +77,36 @@ export class IssuesController {
private readonly refs: Refs,
) {}
@Put('issues/:issue/watch')
@ISSUE
@ApiOperation({
summary: 'Follow a ticket (members, viewers included)',
description:
'Followers are told when it moves, is reassigned or gets a comment. Commenting follows it too.',
})
@ApiOkResponse({ type: CommandResultDto })
@ApiCommandErrors()
async watch(@CurrentUser() me: User, @Param('issue') issue: string) {
return this.issues.watch(
me.id,
await this.refs.issueId(me.id, issue),
true,
);
}
@Delete('issues/:issue/watch')
@ISSUE
@ApiOperation({ summary: 'Stop following a ticket' })
@ApiOkResponse({ type: CommandResultDto })
@ApiCommandErrors()
async unwatch(@CurrentUser() me: User, @Param('issue') issue: string) {
return this.issues.watch(
me.id,
await this.refs.issueId(me.id, issue),
false,
);
}
@Get('me/issues')
@ApiOperation({
summary: 'My work: tickets across all my projects',

View File

@@ -35,6 +35,7 @@ export const issuePageEvents = eventGroup({
/** Uploaded under the new comment once it exists. */
files?: File[];
}>(),
watchRequested: type<{ issueId: string; watching: boolean }>(),
attachRequested: type<{
issueId: string;
files: File[];

View File

@@ -30,6 +30,8 @@ export class IssueFacade {
readonly error = this.store.error;
/** Signed-in user's id (authors can edit their comments). */
readonly me = this.store.me;
/** Whether the signed-in person follows the open ticket. */
readonly watching = this.store.watching;
/** Loads the ticket (by id or key, e.g. BE-3) and follows its changes. */
open(idOrKey: string): void {
@@ -85,6 +87,13 @@ export class IssueFacade {
);
}
/** Follow the ticket (or stop): be told when it moves or gets a comment. */
setWatching(watching: boolean): void {
this.withIssue((issueId) =>
this.dispatch.watchRequested({ issueId, watching }),
);
}
/** Attaches files to the ticket (or under one of its comments). */
attach(files: File[], commentId: string | null = null): void {
if (!files.length) return;

View File

@@ -60,6 +60,11 @@ export const IssueStore = signalStore(
}),
withComputed(({ issue }, auth = inject(AuthFacade)) => ({
me: computed(() => auth.user()?.id ?? null),
watching: computed(
() =>
!!auth.user() &&
(issue()?.watchers ?? []).some((w) => w.id === auth.user()?.id),
),
outwardLinks: computed(
() => issue()?.links.filter((l) => l.direction === 'OUTWARD') ?? [],
),

View File

@@ -3,3 +3,4 @@ export * from './lib/models/models';
export * from './lib/models/inputs';
export * from './lib/models/workflow';
export * from './lib/ports/gateways';
export * from './lib/models/mentions';

View File

@@ -0,0 +1,30 @@
import {
insertMention,
matches,
mentionQuery,
mentionSegments,
} from './mentions';
describe('mentions', () => {
it('splits a text into words and mentions', () => {
expect(mentionSegments('Hola @[Ana](u1), ¿lo miras?')).toEqual([
{ text: 'Hola ' },
{ mention: 'Ana', userId: 'u1' },
{ text: ', ¿lo miras?' },
]);
});
it('notices an @ being typed and completes it', () => {
expect(mentionQuery('Pregunta a @an')).toBe('an');
expect(mentionQuery('Pregunta a @')).toBe('');
expect(mentionQuery('correo@dominio')).toBeNull();
expect(
insertMention('Pregunta a @an', { id: 'u1', name: 'Ana García' }),
).toBe('Pregunta a @[Ana García](u1) ');
});
it('finds people ignoring accents and case', () => {
expect(matches('José Pérez', 'jose pe')).toBe(true);
expect(matches('Ana', 'bob')).toBe(false);
});
});

View File

@@ -0,0 +1,52 @@
/**
* Mentions are stored as `@[Ana García](<user id>)` — the API reads the id to
* tell that person; people see `@Ana García`.
*/
const MENTION = /@\[([^\]\n]{1,80})\]\(([\w-]{1,64})\)/g;
export type TextSegment =
| { text: string }
| { mention: string; userId: string };
/** The text in pieces, to show mentions highlighted. */
export function mentionSegments(text: string): TextSegment[] {
const segments: TextSegment[] = [];
let last = 0;
for (const match of text.matchAll(MENTION)) {
const at = match.index ?? 0;
if (at > last) segments.push({ text: text.slice(last, at) });
segments.push({ mention: match[1], userId: match[2] });
last = at + match[0].length;
}
if (last < text.length) segments.push({ text: text.slice(last) });
return segments;
}
const TYPING = /(^|\s)@([^\s@[\]()]{0,30})$/u;
/** What is being typed after an `@` at the end of the text, or null. */
export function mentionQuery(text: string): string | null {
return TYPING.exec(text)?.[2] ?? null;
}
/** Replaces the `@what-was-typed` at the end with the mention. */
export function insertMention(
text: string,
user: { id: string; name: string },
): string {
const name = user.name.replace(/[[\]()\n]/g, ' ').trim();
return text.replace(
TYPING,
(_, before: string) => `${before}@[${name}](${user.id}) `,
);
}
/** Case- and accent-insensitive "contains". */
export function matches(name: string, query: string): boolean {
const fold = (s: string) =>
s
.normalize('NFD')
.replace(/\p{Diacritic}/gu, '')
.toLowerCase();
return fold(name).includes(fold(query));
}

View File

@@ -86,6 +86,8 @@ export interface Issue extends IssueSummary {
dueDate: string | null;
links: IssueLink[];
attachments: Attachment[];
/** People following the ticket. */
watchers: UserRef[];
allowedTransitions: string[];
createdAt: string;
}

View File

@@ -87,6 +87,8 @@ export abstract class IssuesGateway {
issueId: string,
releaseId: string | null,
): Observable<CommandResult>;
/** The signed-in person follows (or stops following) the ticket. */
abstract watch(issueId: string, watching: boolean): Observable<CommandResult>;
}
export abstract class CommentsGateway {

View File

@@ -25,6 +25,8 @@ import {
SearchIssuesDocument,
SetIssueReleaseDocument,
UnlinkIssuesDocument,
UnwatchIssueDocument,
WatchIssueDocument,
UpdateIssueDocument,
} from '../graphql/generated';
import { GraphqlOperations } from './graphql-operations';
@@ -119,6 +121,16 @@ export class GraphqlIssuesGateway extends IssuesGateway {
.pipe(map((d) => toCommandResult(d.unlinkIssues)));
}
watch(issueId: string, watching: boolean): Observable<CommandResult> {
return watching
? this.gql
.mutate(WatchIssueDocument, { issueId })
.pipe(map((d) => toCommandResult(d.watchIssue)))
: this.gql
.mutate(UnwatchIssueDocument, { issueId })
.pipe(map((d) => toCommandResult(d.unwatchIssue)));
}
setRelease(
issueId: string,
releaseId: string | null,

View File

@@ -88,6 +88,9 @@ fragment IssueFields on Issue {
issueId
issueKey
}
watchers {
...UserRefFields
}
attachments {
id
fileName

View File

@@ -248,6 +248,7 @@ export type IssueFieldsFragment = {
issueId: string;
issueKey: string;
}>;
watchers: Array<{ id: string; name: string }>;
attachments: Array<{
id: string;
fileName: string;
@@ -362,6 +363,7 @@ export type IssueQuery = {
issueId: string;
issueKey: string;
}>;
watchers: Array<{ id: string; name: string }>;
attachments: Array<{
id: string;
fileName: string;
@@ -451,6 +453,22 @@ export type UnlinkIssuesMutation = {
unlinkIssues: { id: string; key: string | null; version: number };
};
export type WatchIssueMutationVariables = Exact<{
issueId: string;
}>;
export type WatchIssueMutation = {
watchIssue: { id: string; key: string | null; version: number };
};
export type UnwatchIssueMutationVariables = Exact<{
issueId: string;
}>;
export type UnwatchIssueMutation = {
unwatchIssue: { id: string; key: string | null; version: number };
};
export type SetIssueReleaseMutationVariables = Exact<{
issueId: string;
releaseId?: string | null | undefined;
@@ -1400,6 +1418,19 @@ export const IssueFieldsFragmentDoc = {
],
},
},
{
kind: 'Field',
name: { kind: 'Name', value: 'watchers' },
selectionSet: {
kind: 'SelectionSet',
selections: [
{
kind: 'FragmentSpread',
name: { kind: 'Name', value: 'UserRefFields' },
},
],
},
},
{
kind: 'Field',
name: { kind: 'Name', value: 'attachments' },
@@ -2066,6 +2097,19 @@ export const IssueDocument = {
],
},
},
{
kind: 'Field',
name: { kind: 'Name', value: 'watchers' },
selectionSet: {
kind: 'SelectionSet',
selections: [
{
kind: 'FragmentSpread',
name: { kind: 'Name', value: 'UserRefFields' },
},
],
},
},
{
kind: 'Field',
name: { kind: 'Name', value: 'attachments' },
@@ -2671,6 +2715,143 @@ export const UnlinkIssuesDocument = {
UnlinkIssuesMutation,
UnlinkIssuesMutationVariables
>;
export const WatchIssueDocument = {
kind: 'Document',
definitions: [
{
kind: 'OperationDefinition',
operation: 'mutation',
name: { kind: 'Name', value: 'WatchIssue' },
variableDefinitions: [
{
kind: 'VariableDefinition',
variable: {
kind: 'Variable',
name: { kind: 'Name', value: 'issueId' },
},
type: {
kind: 'NonNullType',
type: { kind: 'NamedType', name: { kind: 'Name', value: 'ID' } },
},
},
],
selectionSet: {
kind: 'SelectionSet',
selections: [
{
kind: 'Field',
name: { kind: 'Name', value: 'watchIssue' },
arguments: [
{
kind: 'Argument',
name: { kind: 'Name', value: 'issueId' },
value: {
kind: 'Variable',
name: { kind: 'Name', value: 'issueId' },
},
},
],
selectionSet: {
kind: 'SelectionSet',
selections: [
{
kind: 'FragmentSpread',
name: { kind: 'Name', value: 'CommandResultFields' },
},
],
},
},
],
},
},
{
kind: 'FragmentDefinition',
name: { kind: 'Name', value: 'CommandResultFields' },
typeCondition: {
kind: 'NamedType',
name: { kind: 'Name', value: 'CommandResult' },
},
selectionSet: {
kind: 'SelectionSet',
selections: [
{ kind: 'Field', name: { kind: 'Name', value: 'id' } },
{ kind: 'Field', name: { kind: 'Name', value: 'key' } },
{ kind: 'Field', name: { kind: 'Name', value: 'version' } },
],
},
},
],
} as unknown as DocumentNode<WatchIssueMutation, WatchIssueMutationVariables>;
export const UnwatchIssueDocument = {
kind: 'Document',
definitions: [
{
kind: 'OperationDefinition',
operation: 'mutation',
name: { kind: 'Name', value: 'UnwatchIssue' },
variableDefinitions: [
{
kind: 'VariableDefinition',
variable: {
kind: 'Variable',
name: { kind: 'Name', value: 'issueId' },
},
type: {
kind: 'NonNullType',
type: { kind: 'NamedType', name: { kind: 'Name', value: 'ID' } },
},
},
],
selectionSet: {
kind: 'SelectionSet',
selections: [
{
kind: 'Field',
name: { kind: 'Name', value: 'unwatchIssue' },
arguments: [
{
kind: 'Argument',
name: { kind: 'Name', value: 'issueId' },
value: {
kind: 'Variable',
name: { kind: 'Name', value: 'issueId' },
},
},
],
selectionSet: {
kind: 'SelectionSet',
selections: [
{
kind: 'FragmentSpread',
name: { kind: 'Name', value: 'CommandResultFields' },
},
],
},
},
],
},
},
{
kind: 'FragmentDefinition',
name: { kind: 'Name', value: 'CommandResultFields' },
typeCondition: {
kind: 'NamedType',
name: { kind: 'Name', value: 'CommandResult' },
},
selectionSet: {
kind: 'SelectionSet',
selections: [
{ kind: 'Field', name: { kind: 'Name', value: 'id' } },
{ kind: 'Field', name: { kind: 'Name', value: 'key' } },
{ kind: 'Field', name: { kind: 'Name', value: 'version' } },
],
},
},
],
} as unknown as DocumentNode<
UnwatchIssueMutation,
UnwatchIssueMutationVariables
>;
export const SetIssueReleaseDocument = {
kind: 'Document',
definitions: [

View File

@@ -66,6 +66,18 @@ mutation UnlinkIssues($input: LinkIssuesInput!) {
}
}
mutation WatchIssue($issueId: ID!) {
watchIssue(issueId: $issueId) {
...CommandResultFields
}
}
mutation UnwatchIssue($issueId: ID!) {
unwatchIssue(issueId: $issueId) {
...CommandResultFields
}
}
mutation SetIssueRelease($issueId: ID!, $releaseId: ID) {
setIssueRelease(issueId: $issueId, releaseId: $releaseId) {
...CommandResultFields

View File

@@ -345,3 +345,39 @@ a.ts-key {
gap: 0.5rem;
margin: 0.5rem 0;
}
.ts-mention {
padding: 0 0.2em;
border-radius: 0.25em;
background: color-mix(in oklab, var(--ts-brand) 14%, transparent);
color: inherit;
font-weight: 600;
}
.ts-mention.is-me {
background: color-mix(in oklab, var(--ts-brand) 30%, transparent);
}
.ts-mentions {
display: flex;
flex-wrap: wrap;
gap: 0.375rem;
}
.ts-mentions__option {
display: inline-flex;
align-items: center;
gap: 0.5rem;
padding: 0.25rem 0.625rem 0.25rem 0.25rem;
border: 1px solid rgba(127, 127, 127, 0.3);
border-radius: 999px;
background: transparent;
color: inherit;
font: inherit;
cursor: pointer;
}
.ts-mentions__option:hover,
.ts-mentions__option:focus-visible {
border-color: currentColor;
}

View File

@@ -35,9 +35,13 @@ import {
import {
Attachment,
Comment,
insertMention,
IssueLink,
IssueLinkType,
IssuePriority,
matches,
mentionQuery,
mentionSegments,
ReleaseStatus,
} from '@task-stream/web/tracker/domain';
import {
@@ -95,6 +99,20 @@ const MAX_FILE_BYTES = 25 * 1024 * 1024;
</div>
<div class="ts-issue__title-row">
<h1>{{ issue.title }}</h1>
@if (project.myRole()) {
<pi-action-button
size="small"
color="grey"
appearance="outline"
[text]="
facade.watching()
? 'Siguiendo · ' + issue.watchers.length
: 'Seguir'
"
[attr.aria-pressed]="facade.watching()"
(execute)="facade.setWatching(!facade.watching())"
/>
}
@if (project.canContribute()) {
<pi-icon-button
icon="edit"
@@ -287,7 +305,19 @@ const MAX_FILE_BYTES = 25 * 1024 * 1024;
/>
}
</div>
<p class="ts-prewrap">{{ comment.body }}</p>
<p class="ts-prewrap">
@for (part of segments(comment.body); track $index) {
@if ('mention' in part) {
<span
class="ts-mention"
[class.is-me]="part.userId === facade.me()"
>@{{ part.mention }}</span
>
} @else {
{{ part.text }}
}
}
</p>
@if (filesOf(comment).length) {
<div class="ts-attachments">
@for (file of filesOf(comment); track file.id) {
@@ -308,9 +338,29 @@ const MAX_FILE_BYTES = 25 * 1024 * 1024;
novalidate
>
<pi-textarea
placeholder="Deja una decisión, una explicación, un paso para reproducir…"
placeholder="Deja una decisión, una explicación, un paso para reproducir… Escribe @ para mencionar a alguien."
[(value)]="commentBody"
/>
@if (mentionOptions().length) {
<div
class="ts-mentions"
role="group"
aria-label="Mencionar a"
>
@for (member of mentionOptions(); track member.id) {
<button
type="button"
class="ts-mentions__option"
(click)="mention(member)"
>
<span class="ts-comment__avatar" aria-hidden="true">{{
initialsOf(member.name)
}}</span>
{{ member.name }}
</button>
}
</div>
}
<pi-file-upload
label="Adjuntar al comentario"
[multiple]="true"
@@ -576,6 +626,19 @@ export class IssuePage {
protected readonly linkTarget = signal('');
protected readonly commentBody = signal('');
protected readonly commentFiles = signal<File[]>([]);
protected readonly segments = mentionSegments;
/** Members matching what is typed after an `@` at the end of the comment. */
protected readonly mentionOptions = computed(() => {
const query = mentionQuery(this.commentBody());
if (query === null) return [];
return this.project
.members()
.map((m) => m.user)
.filter(
(user) => user.id !== this.facade.me() && matches(user.name, query),
)
.slice(0, 6);
});
protected readonly pendingFiles = signal<File[]>([]);
protected readonly maxFileBytes = MAX_FILE_BYTES;
@@ -679,6 +742,10 @@ export class IssuePage {
this.commentFiles.set([]);
}
protected mention(user: { id: string; name: string }): void {
this.commentBody.update((text) => insertMention(text, user));
}
protected attach(): void {
this.facade.attach(this.pendingFiles());
this.pendingFiles.set([]);

View File

@@ -190,7 +190,7 @@ const ICON: Record<NotificationKind, string> = {
height: 0.5rem;
margin-top: 0.5rem;
border-radius: 50%;
background: #a347e1;
background: var(--ts-brand, #a347e1);
}
.ts-inbox__empty {
opacity: 0.7;