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:
@@ -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": {
|
"/api/me/issues": {
|
||||||
"get": {
|
"get": {
|
||||||
"description": "Assigned to me by default; with assignedToMe=false, any ticket of my projects (e.g. waiting for QA).",
|
"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"
|
"$ref": "#/components/schemas/AttachmentDto"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"watchers": {
|
||||||
|
"description": "People following it",
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/components/schemas/UserRefDto"
|
||||||
|
}
|
||||||
|
},
|
||||||
"commentCount": {
|
"commentCount": {
|
||||||
"type": "number"
|
"type": "number"
|
||||||
},
|
},
|
||||||
@@ -6170,6 +6378,7 @@
|
|||||||
"dueDate",
|
"dueDate",
|
||||||
"links",
|
"links",
|
||||||
"attachments",
|
"attachments",
|
||||||
|
"watchers",
|
||||||
"commentCount",
|
"commentCount",
|
||||||
"createdAt",
|
"createdAt",
|
||||||
"updatedAt",
|
"updatedAt",
|
||||||
|
|||||||
@@ -220,6 +220,9 @@ type Issue {
|
|||||||
type: String!
|
type: String!
|
||||||
updatedAt: String!
|
updatedAt: String!
|
||||||
version: Int!
|
version: Int!
|
||||||
|
|
||||||
|
"""People following the ticket"""
|
||||||
|
watchers: [UserRef!]!
|
||||||
}
|
}
|
||||||
|
|
||||||
type IssueChange {
|
type IssueChange {
|
||||||
@@ -359,6 +362,9 @@ type Mutation {
|
|||||||
"""Put a ticket in a release (null removes it)"""
|
"""Put a ticket in a release (null removes it)"""
|
||||||
setIssueRelease(issueId: ID!, releaseId: ID): CommandResult!
|
setIssueRelease(issueId: ID!, releaseId: ID): CommandResult!
|
||||||
unlinkIssues(input: LinkIssuesInput!): CommandResult!
|
unlinkIssues(input: LinkIssuesInput!): CommandResult!
|
||||||
|
|
||||||
|
"""Stop following a ticket"""
|
||||||
|
unwatchIssue(issueId: ID!): CommandResult!
|
||||||
updateIssue(input: UpdateIssueInput!): CommandResult!
|
updateIssue(input: UpdateIssueInput!): CommandResult!
|
||||||
updateNotificationSettings(email: Boolean!): NotificationSettings!
|
updateNotificationSettings(email: Boolean!): NotificationSettings!
|
||||||
updateProject(input: UpdateProjectInput!): CommandResult!
|
updateProject(input: UpdateProjectInput!): CommandResult!
|
||||||
@@ -368,6 +374,11 @@ type Mutation {
|
|||||||
"""
|
"""
|
||||||
updateProjectSettings(projectId: ID!, settings: ProjectSettingsInput!): CommandResult!
|
updateProjectSettings(projectId: ID!, settings: ProjectSettingsInput!): CommandResult!
|
||||||
updateRelease(input: UpdateReleaseInput!): 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 {
|
type MyNotifications {
|
||||||
|
|||||||
@@ -301,6 +301,27 @@ export interface paths {
|
|||||||
patch: operations["ProjectsController_changeMemberRole"];
|
patch: operations["ProjectsController_changeMemberRole"];
|
||||||
trace?: never;
|
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": {
|
"/api/me/issues": {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@@ -1093,6 +1114,8 @@ export interface components {
|
|||||||
dueDate: string | null;
|
dueDate: string | null;
|
||||||
links: components["schemas"]["IssueLinkDto"][];
|
links: components["schemas"]["IssueLinkDto"][];
|
||||||
attachments: components["schemas"]["AttachmentDto"][];
|
attachments: components["schemas"]["AttachmentDto"][];
|
||||||
|
/** @description People following it */
|
||||||
|
watchers: components["schemas"]["UserRefDto"][];
|
||||||
commentCount: number;
|
commentCount: number;
|
||||||
/** Format: date-time */
|
/** Format: date-time */
|
||||||
createdAt: string;
|
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: {
|
IssuesController_mine: {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: {
|
query?: {
|
||||||
|
|||||||
@@ -13,7 +13,8 @@
|
|||||||
| 🧪 QA | Por defecto, `READY_FOR_QA → IN_QA → DONE / QA_FAILED`; fallar QA exige un motivo |
|
| 🧪 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) |
|
| 🚀 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 |
|
| 👥 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 |
|
| 🔔 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 |
|
| 📎 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) |
|
| 🔗 Relaciones | `BLOCKS`, `DEPENDS_ON`, `TESTS`, `DUPLICATES`, `RELATES_TO`, también entre proyectos (FE ↔ BE ↔ QA) |
|
||||||
|
|||||||
@@ -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.
|
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 |
|
| Saga | Evento | Comandos |
|
||||||
| --------------------- | ------------------------------------------ | ----------------------------------------------------------------------------------------------------------- |
|
| ---------------------- | ------------------------------------------ | ----------------------------------------------------------------------------------------------------------- |
|
||||||
| `ProjectDeletionSaga` | `ProjectDeletedEvent` | `DeleteIssueCommand` por cada ticket y `DeleteReleaseCommand` por cada release del proyecto |
|
| `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 |
|
| | `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) |
|
| `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») |
|
| `UserDeactivatedSaga` | `UserDeactivatedEvent` (contexto identity) | `UnassignIssueCommand` para sus tickets abiertos en todos los proyectos, como actor `system` («TaskStream») |
|
||||||
|
|
||||||
Reglas que cumplen:
|
Reglas que cumplen:
|
||||||
|
|||||||
@@ -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` |
|
| Alguien comenta | Quien lo tiene asignado y quien lo abrió | `COMMENTED` |
|
||||||
| Te añaden a un proyecto | La persona añadida | `ADDED_TO_PROJECT` |
|
| 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
|
`<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
|
`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.
|
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`).
|
Proyectos y tickets se pueden indicar por **id o por clave** (`WEB`, `WEB-12`).
|
||||||
|
|
||||||
| Recurso | Rutas |
|
| Recurso | Rutas |
|
||||||
| ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
| ----------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
| Usuarios | `GET /api/users/me`, `GET /api/users?search=`, `GET /api/users/{id}` |
|
| 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) |
|
| 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}` |
|
| Comentarios | `PATCH/DELETE /api/comments/{commentId}` |
|
||||||
| Avisos | `GET /api/me/notifications`, `POST /api/me/notifications/read`, `GET/PUT /api/me/notification-settings` |
|
| 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}` |
|
| Adjuntos | `POST /api/issues/{issue}/attachments` (multipart: `file` y opcional `commentId`), `GET/DELETE /api/issues/{issue}/attachments/{id}` |
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ import { DetachIssueFromReleaseCommandHandler } from './detach-issue-from-releas
|
|||||||
import { UnassignIssueCommandHandler } from './unassign-issue.command-handler';
|
import { UnassignIssueCommandHandler } from './unassign-issue.command-handler';
|
||||||
import { AttachFileCommandHandler } from './attach-file.command-handler';
|
import { AttachFileCommandHandler } from './attach-file.command-handler';
|
||||||
import { DetachFileCommandHandler } from './detach-file.command-handler';
|
import { DetachFileCommandHandler } from './detach-file.command-handler';
|
||||||
|
import { WatchIssueCommandHandler } from './watch-issue.command-handler';
|
||||||
|
|
||||||
export const COMMAND_HANDLERS = [
|
export const COMMAND_HANDLERS = [
|
||||||
CreateProjectCommandHandler,
|
CreateProjectCommandHandler,
|
||||||
@@ -59,4 +60,5 @@ export const COMMAND_HANDLERS = [
|
|||||||
UnassignIssueCommandHandler,
|
UnassignIssueCommandHandler,
|
||||||
AttachFileCommandHandler,
|
AttachFileCommandHandler,
|
||||||
DetachFileCommandHandler,
|
DetachFileCommandHandler,
|
||||||
|
WatchIssueCommandHandler,
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ import { AddProjectMemberCommand } from './add-project-member.command';
|
|||||||
import { AssignIssueCommand } from './assign-issue.command';
|
import { AssignIssueCommand } from './assign-issue.command';
|
||||||
import { AttachFileCommand } from './attach-file.command';
|
import { AttachFileCommand } from './attach-file.command';
|
||||||
import { DetachFileCommand } from './detach-file.command';
|
import { DetachFileCommand } from './detach-file.command';
|
||||||
|
import { WatchIssueCommand } from './watch-issue.command';
|
||||||
import { ChangeIssueStatusCommand } from './change-issue-status.command';
|
import { ChangeIssueStatusCommand } from './change-issue-status.command';
|
||||||
import { COMMAND_HANDLERS } from './command-handlers';
|
import { COMMAND_HANDLERS } from './command-handlers';
|
||||||
import { CommandResult } from './command-result';
|
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 () => {
|
it('does not let viewers modify tickets', async () => {
|
||||||
const { run } = await setup();
|
const { run } = await setup();
|
||||||
const projectId = await projectWithTeam(run);
|
const projectId = await projectWithTeam(run);
|
||||||
|
|||||||
@@ -28,3 +28,4 @@ export * from './detach-issue-from-release.command';
|
|||||||
export * from './unassign-issue.command';
|
export * from './unassign-issue.command';
|
||||||
export * from './attach-file.command';
|
export * from './attach-file.command';
|
||||||
export * from './detach-file.command';
|
export * from './detach-file.command';
|
||||||
|
export * from './watch-issue.command';
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -27,6 +27,7 @@ import { ReleaseDeletedEventHandler } from './release-deleted.event-handler';
|
|||||||
import { IssueAttachmentAddedEventHandler } from './issue-attachment-added.event-handler';
|
import { IssueAttachmentAddedEventHandler } from './issue-attachment-added.event-handler';
|
||||||
import { IssueAttachmentRemovedEventHandler } from './issue-attachment-removed.event-handler';
|
import { IssueAttachmentRemovedEventHandler } from './issue-attachment-removed.event-handler';
|
||||||
import { AttachmentCleanupEventHandler } from './attachment-cleanup.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. */
|
/** Read-model projections (MongoDB) + realtime, fed by the EventsBridge. */
|
||||||
export const EVENT_HANDLERS = [
|
export const EVENT_HANDLERS = [
|
||||||
@@ -58,6 +59,7 @@ export const EVENT_HANDLERS = [
|
|||||||
IssueAttachmentAddedEventHandler,
|
IssueAttachmentAddedEventHandler,
|
||||||
IssueAttachmentRemovedEventHandler,
|
IssueAttachmentRemovedEventHandler,
|
||||||
AttachmentCleanupEventHandler,
|
AttachmentCleanupEventHandler,
|
||||||
|
IssueWatchersEventHandler,
|
||||||
];
|
];
|
||||||
|
|
||||||
export { ReadModelNotifier };
|
export { ReadModelNotifier };
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
IncomingFile,
|
IncomingFile,
|
||||||
} from './commands/attach-file.command';
|
} from './commands/attach-file.command';
|
||||||
import { DetachFileCommand } from './commands/detach-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 { ChangeIssueStatusCommand } from './commands/change-issue-status.command';
|
||||||
import { CommandResult } from './commands/command-result';
|
import { CommandResult } from './commands/command-result';
|
||||||
import { CreateIssueCommand } from './commands/create-issue.command';
|
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. */
|
/** A ticket's file, with its bytes, for whoever can read the ticket. */
|
||||||
download(
|
download(
|
||||||
viewerId: string | null,
|
viewerId: string | null,
|
||||||
|
|||||||
@@ -4,10 +4,12 @@ import {
|
|||||||
IssueAssignedEvent,
|
IssueAssignedEvent,
|
||||||
IssueCreatedEvent,
|
IssueCreatedEvent,
|
||||||
IssueStatusChangedEvent,
|
IssueStatusChangedEvent,
|
||||||
|
mentionedUserIds,
|
||||||
NotificationKind,
|
NotificationKind,
|
||||||
ProjectMemberAddedEvent,
|
ProjectMemberAddedEvent,
|
||||||
StatusCategory,
|
StatusCategory,
|
||||||
TrackerEvent,
|
TrackerEvent,
|
||||||
|
withoutMentionMarkup,
|
||||||
} from '@task-stream/api/tracker/domain';
|
} from '@task-stream/api/tracker/domain';
|
||||||
import { FindIssuesRepository } from '../ports/find-issues.repository';
|
import { FindIssuesRepository } from '../ports/find-issues.repository';
|
||||||
import { FindProjectsRepository } from '../ports/find-projects.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. */
|
/* Read by people, like the activity feed: Spanish. */
|
||||||
|
|
||||||
const excerpt = (text: string, max = 120) => {
|
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;
|
return flat.length > max ? `${flat.slice(0, max - 1)}…` : flat;
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Who is told what, for the events that concern someone in particular:
|
* 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
|
* whoever is mentioned, whoever a ticket is assigned to, whoever reported or
|
||||||
* moves or gets a comment, whoever joins a project.
|
* 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(
|
@EventsHandler(
|
||||||
IssueCreatedEvent,
|
IssueCreatedEvent,
|
||||||
@@ -81,6 +85,9 @@ export class NotificationsEventHandler implements IEventHandler<TrackerEvent> {
|
|||||||
const assigneeId =
|
const assigneeId =
|
||||||
'assigneeId' in issue ? issue.assigneeId : (issue.assignee?.id ?? null);
|
'assigneeId' in issue ? issue.assigneeId : (issue.assignee?.id ?? null);
|
||||||
const ticket = `${issue.key}: ${issue.title}`;
|
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[] = [];
|
const drafts: NotificationDraft[] = [];
|
||||||
if (event instanceof IssueCreatedEvent && assigneeId) {
|
if (event instanceof IssueCreatedEvent && assigneeId) {
|
||||||
@@ -90,13 +97,27 @@ export class NotificationsEventHandler implements IEventHandler<TrackerEvent> {
|
|||||||
summary: `${actor} te asignó ${ticket}`,
|
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({
|
drafts.push({
|
||||||
userId: event.assigneeId,
|
userId: event.assigneeId,
|
||||||
kind: NotificationKind.ASSIGNED,
|
kind: NotificationKind.ASSIGNED,
|
||||||
summary: `${actor} te asignó ${ticket}`,
|
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) {
|
if (event instanceof IssueStatusChangedEvent) {
|
||||||
const to = event.meta?.toName ?? event.to;
|
const to = event.meta?.toName ?? event.to;
|
||||||
// Back from QA without passing: the one move whoever did the work must hear about.
|
// 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.QA &&
|
||||||
event.meta?.toCategory !== StatusCategory.DONE;
|
event.meta?.toCategory !== StatusCategory.DONE;
|
||||||
const reason = event.reason ? `: ${excerpt(event.reason)}` : '';
|
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]) {
|
for (const userId of [assigneeId, reporterId]) {
|
||||||
if (!userId) continue;
|
if (!userId) continue;
|
||||||
drafts.push({
|
drafts.push({
|
||||||
@@ -112,19 +136,37 @@ export class NotificationsEventHandler implements IEventHandler<TrackerEvent> {
|
|||||||
kind: failedQa
|
kind: failedQa
|
||||||
? NotificationKind.QA_FAILED
|
? NotificationKind.QA_FAILED
|
||||||
: NotificationKind.STATUS_CHANGED,
|
: NotificationKind.STATUS_CHANGED,
|
||||||
summary: failedQa
|
summary,
|
||||||
? `${actor} devolvió ${ticket} desde QA${reason}`
|
|
||||||
: `${actor} movió ${ticket} a ${to}${reason}`,
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
for (const userId of watcherIds) {
|
||||||
|
drafts.push({ userId, kind: NotificationKind.WATCHED, summary });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (event instanceof CommentAddedEvent) {
|
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]) {
|
for (const userId of [assigneeId, reporterId]) {
|
||||||
if (!userId) continue;
|
if (!userId) continue;
|
||||||
drafts.push({
|
drafts.push({
|
||||||
userId,
|
userId,
|
||||||
kind: NotificationKind.COMMENTED,
|
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}`,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -54,13 +54,21 @@ function setup(mailEnabled = false) {
|
|||||||
const repo = new InMemoryNotifications();
|
const repo = new InMemoryNotifications();
|
||||||
const published: NotificationReadModel[] = [];
|
const published: NotificationReadModel[] = [];
|
||||||
const mailed: string[] = [];
|
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 = {
|
const issue = {
|
||||||
id: 'i-1',
|
id: 'i-1',
|
||||||
key: 'WEB-1',
|
key: 'WEB-1',
|
||||||
title: 'Login roto',
|
title: 'Login roto',
|
||||||
reporter: { id: 'qa', name: 'QA' },
|
reporter: { id: 'qa', name: 'QA' },
|
||||||
assignee: { id: 'bob', name: 'Bob' },
|
assignee: { id: 'bob', name: 'Bob' },
|
||||||
|
watchers: [
|
||||||
|
{ id: 'pm', name: 'PM' },
|
||||||
|
{ id: 'bob', name: 'Bob' },
|
||||||
|
],
|
||||||
};
|
};
|
||||||
const notifier = new Notifier(
|
const notifier = new Notifier(
|
||||||
repo,
|
repo,
|
||||||
@@ -81,7 +89,15 @@ function setup(mailEnabled = false) {
|
|||||||
notifier,
|
notifier,
|
||||||
{ findById: async () => issue } as unknown as FindIssuesRepository,
|
{ 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,
|
} as unknown as FindProjectsRepository,
|
||||||
users,
|
users,
|
||||||
);
|
);
|
||||||
@@ -100,8 +116,12 @@ describe('notifications', () => {
|
|||||||
|
|
||||||
expect(repo.all.map((n) => [n.userId, n.kind])).toEqual([
|
expect(repo.all.map((n) => [n.userId, n.kind])).toEqual([
|
||||||
['bob', NotificationKind.ASSIGNED],
|
['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');
|
expect(repo.all[0].summary).toContain('WEB-1');
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -129,6 +149,7 @@ describe('notifications', () => {
|
|||||||
expect(repo.all.map((n) => [n.userId, n.kind])).toEqual([
|
expect(repo.all.map((n) => [n.userId, n.kind])).toEqual([
|
||||||
['bob', NotificationKind.QA_FAILED],
|
['bob', NotificationKind.QA_FAILED],
|
||||||
['qa', NotificationKind.QA_FAILED],
|
['qa', NotificationKind.QA_FAILED],
|
||||||
|
['pm', NotificationKind.WATCHED],
|
||||||
]);
|
]);
|
||||||
expect(repo.all[0].summary).toContain('Falla en Safari');
|
expect(repo.all[0].summary).toContain('Falla en Safari');
|
||||||
});
|
});
|
||||||
@@ -151,7 +172,32 @@ describe('notifications', () => {
|
|||||||
await handler.handle(comment);
|
await handler.handle(comment);
|
||||||
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 () => {
|
it('copies by e-mail only to whoever asked for it, when mail is configured', async () => {
|
||||||
|
|||||||
@@ -3,13 +3,19 @@ import {
|
|||||||
IssueLinkReadModel,
|
IssueLinkReadModel,
|
||||||
IssueReadModel,
|
IssueReadModel,
|
||||||
StatusCategory,
|
StatusCategory,
|
||||||
|
UserRefReadModel,
|
||||||
} from '@task-stream/api/tracker/domain';
|
} from '@task-stream/api/tracker/domain';
|
||||||
|
|
||||||
export abstract class UpsertMaterializedIssueRepository {
|
export abstract class UpsertMaterializedIssueRepository {
|
||||||
/** Applied only if `version` is newer than the stored one (idempotent). */
|
/** Applied only if `version` is newer than the stored one (idempotent). */
|
||||||
abstract upsert(
|
abstract upsert(
|
||||||
issue: Pick<IssueReadModel, 'id' | 'version'> &
|
issue: Pick<IssueReadModel, 'id' | 'version'> &
|
||||||
Partial<Omit<IssueReadModel, 'links' | 'commentCount' | 'attachments'>>,
|
Partial<
|
||||||
|
Omit<
|
||||||
|
IssueReadModel,
|
||||||
|
'links' | 'commentCount' | 'attachments' | 'watchers'
|
||||||
|
>
|
||||||
|
>,
|
||||||
): Promise<void>;
|
): Promise<void>;
|
||||||
/** Adds a relation to an issue (idempotent). */
|
/** Adds a relation to an issue (idempotent). */
|
||||||
abstract addLink(issueId: string, link: IssueLinkReadModel): Promise<void>;
|
abstract addLink(issueId: string, link: IssueLinkReadModel): Promise<void>;
|
||||||
@@ -26,6 +32,9 @@ export abstract class UpsertMaterializedIssueRepository {
|
|||||||
issueId: string,
|
issueId: string,
|
||||||
attachmentId: string,
|
attachmentId: string,
|
||||||
): Promise<void>;
|
): Promise<void>;
|
||||||
|
/** Adds a follower (idempotent). */
|
||||||
|
abstract addWatcher(issueId: string, user: UserRefReadModel): Promise<void>;
|
||||||
|
abstract removeWatcher(issueId: string, userId: string): Promise<void>;
|
||||||
abstract setCommentCount(
|
abstract setCommentCount(
|
||||||
issueId: string,
|
issueId: string,
|
||||||
commentCount: number,
|
commentCount: number,
|
||||||
|
|||||||
@@ -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')]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,10 +1,12 @@
|
|||||||
import { ProjectDeletionSaga } from './project-deletion.saga';
|
import { ProjectDeletionSaga } from './project-deletion.saga';
|
||||||
import { ReleaseArchivedSaga } from './release-archived.saga';
|
import { ReleaseArchivedSaga } from './release-archived.saga';
|
||||||
import { UserDeactivatedSaga } from './user-deactivated.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). */
|
/** Reactions to events that change other aggregates (run by the events bridge). */
|
||||||
export const SAGAS = [
|
export const SAGAS = [
|
||||||
ProjectDeletionSaga,
|
ProjectDeletionSaga,
|
||||||
ReleaseArchivedSaga,
|
ReleaseArchivedSaga,
|
||||||
UserDeactivatedSaga,
|
UserDeactivatedSaga,
|
||||||
|
CommentersFollowSaga,
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ export * from './lib/project/project-role';
|
|||||||
export * from './lib/issue/issue';
|
export * from './lib/issue/issue';
|
||||||
export * from './lib/issue/issue.enums';
|
export * from './lib/issue/issue.enums';
|
||||||
export * from './lib/issue/issue-attachment';
|
export * from './lib/issue/issue-attachment';
|
||||||
|
export * from './lib/issue/mentions';
|
||||||
export * from './lib/project/project-settings';
|
export * from './lib/project/project-settings';
|
||||||
export * from './lib/comment/comment';
|
export * from './lib/comment/comment';
|
||||||
export * from './lib/release/release';
|
export * from './lib/release/release';
|
||||||
|
|||||||
@@ -24,3 +24,5 @@ export * from './issue-deleted.event';
|
|||||||
export * from './release-deleted.event';
|
export * from './release-deleted.event';
|
||||||
export * from './issue-attachment-added.event';
|
export * from './issue-attachment-added.event';
|
||||||
export * from './issue-attachment-removed.event';
|
export * from './issue-attachment-removed.event';
|
||||||
|
export * from './issue-watched.event';
|
||||||
|
export * from './issue-unwatched.event';
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,6 +8,7 @@ import { IssueStatusChangedEvent } from '../events/issue-status-changed.event';
|
|||||||
import { IssueFactory } from '../factories/issue.factory';
|
import { IssueFactory } from '../factories/issue.factory';
|
||||||
import { replay } from '../testing';
|
import { replay } from '../testing';
|
||||||
import { Issue } from './issue';
|
import { Issue } from './issue';
|
||||||
|
import { mentionedUserIds, withoutMentionMarkup } from './mentions';
|
||||||
import { IssueLinkType, IssueStatus, IssueType } from './issue.enums';
|
import { IssueLinkType, IssueStatus, IssueType } from './issue.enums';
|
||||||
|
|
||||||
const factory = new IssueFactory();
|
const factory = new IssueFactory();
|
||||||
@@ -206,4 +207,31 @@ describe('Issue', () => {
|
|||||||
expect(rebuilt.attachments.map((a) => a.id)).toEqual(['a-2']);
|
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',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -14,6 +14,8 @@ import { IssueReleaseChangedEvent } from '../events/issue-release-changed.event'
|
|||||||
import { IssueStatusChangedEvent } from '../events/issue-status-changed.event';
|
import { IssueStatusChangedEvent } from '../events/issue-status-changed.event';
|
||||||
import { IssueUnlinkedEvent } from '../events/issue-unlinked.event';
|
import { IssueUnlinkedEvent } from '../events/issue-unlinked.event';
|
||||||
import { IssueUpdatedEvent } from '../events/issue-updated.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 { ProjectWorkflow } from '../project/project-settings';
|
||||||
import {
|
import {
|
||||||
IssueAttachment,
|
IssueAttachment,
|
||||||
@@ -91,6 +93,8 @@ export class Issue extends VersionedAggregateRoot implements IssueProps {
|
|||||||
public dueDate: string | null = null;
|
public dueDate: string | null = null;
|
||||||
public links: IssueLink[] = [];
|
public links: IssueLink[] = [];
|
||||||
public attachments: IssueAttachment[] = [];
|
public attachments: IssueAttachment[] = [];
|
||||||
|
/** People who asked to be told about this ticket. */
|
||||||
|
public watchers: string[] = [];
|
||||||
public createdAt!: string;
|
public createdAt!: string;
|
||||||
public updatedAt!: string;
|
public updatedAt!: string;
|
||||||
public deleted = false;
|
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. */
|
/** Deletes the ticket (its project was deleted). Idempotent. */
|
||||||
delete(actorId: string): void {
|
delete(actorId: string): void {
|
||||||
if (this.deleted) return;
|
if (this.deleted) return;
|
||||||
@@ -334,6 +352,14 @@ export class Issue extends VersionedAggregateRoot implements IssueProps {
|
|||||||
this.updatedAt = event.occurredAt;
|
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 {
|
onIssueDeletedEvent(): void {
|
||||||
this.deleted = true;
|
this.deleted = true;
|
||||||
}
|
}
|
||||||
|
|||||||
17
libs/api/tracker/domain/src/lib/issue/mentions.ts
Normal file
17
libs/api/tracker/domain/src/lib/issue/mentions.ts
Normal 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}`);
|
||||||
|
}
|
||||||
@@ -47,6 +47,8 @@ export class IssueReadModel {
|
|||||||
dueDate!: string | null;
|
dueDate!: string | null;
|
||||||
links!: IssueLinkReadModel[];
|
links!: IssueLinkReadModel[];
|
||||||
attachments!: AttachmentReadModel[];
|
attachments!: AttachmentReadModel[];
|
||||||
|
/** People following the ticket. */
|
||||||
|
watchers!: UserRefReadModel[];
|
||||||
commentCount!: number;
|
commentCount!: number;
|
||||||
createdAt!: string;
|
createdAt!: string;
|
||||||
updatedAt!: string;
|
updatedAt!: string;
|
||||||
|
|||||||
@@ -77,6 +77,7 @@ export const IssueSchema = new Schema<IssueDoc>(
|
|||||||
release: { _id: false, id: String, name: String },
|
release: { _id: false, id: String, name: String },
|
||||||
outwardLinks: [link],
|
outwardLinks: [link],
|
||||||
inwardLinks: [link],
|
inwardLinks: [link],
|
||||||
|
watchers: [userRef],
|
||||||
attachments: [
|
attachments: [
|
||||||
{
|
{
|
||||||
_id: false,
|
_id: false,
|
||||||
@@ -97,6 +98,7 @@ export const IssueSchema = new Schema<IssueDoc>(
|
|||||||
IssueSchema.index({ projectId: 1, status: 1, updatedAt: -1 });
|
IssueSchema.index({ projectId: 1, status: 1, updatedAt: -1 });
|
||||||
IssueSchema.index({ projectId: 1, statusCategory: 1 });
|
IssueSchema.index({ projectId: 1, statusCategory: 1 });
|
||||||
IssueSchema.index({ 'assignee.id': 1 });
|
IssueSchema.index({ 'assignee.id': 1 });
|
||||||
|
IssueSchema.index({ 'watchers.id': 1 });
|
||||||
IssueSchema.index({ 'release.id': 1 });
|
IssueSchema.index({ 'release.id': 1 });
|
||||||
IssueSchema.index({ title: 'text', description: 'text', key: 'text' });
|
IssueSchema.index({ title: 'text', description: 'text', key: 'text' });
|
||||||
|
|
||||||
@@ -179,5 +181,6 @@ export function issueFromDoc(doc: IssueDoc): IssueReadModel {
|
|||||||
links: [...(outwardLinks ?? []), ...(inwardLinks ?? [])],
|
links: [...(outwardLinks ?? []), ...(inwardLinks ?? [])],
|
||||||
// Tickets projected before attachments existed have none.
|
// Tickets projected before attachments existed have none.
|
||||||
attachments: rest.attachments ?? [],
|
attachments: rest.attachments ?? [],
|
||||||
|
watchers: rest.watchers ?? [],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import {
|
|||||||
ProjectReadModel,
|
ProjectReadModel,
|
||||||
ReleaseReadModel,
|
ReleaseReadModel,
|
||||||
StatusCategory,
|
StatusCategory,
|
||||||
|
UserRefReadModel,
|
||||||
} from '@task-stream/api/tracker/domain';
|
} from '@task-stream/api/tracker/domain';
|
||||||
import { Model } from 'mongoose';
|
import { Model } from 'mongoose';
|
||||||
import {
|
import {
|
||||||
@@ -100,15 +101,35 @@ export class MongoUpsertMaterializedIssueRepository extends UpsertMaterializedIs
|
|||||||
version,
|
version,
|
||||||
...fields
|
...fields
|
||||||
}: Pick<IssueReadModel, 'id' | 'version'> &
|
}: Pick<IssueReadModel, 'id' | 'version'> &
|
||||||
Partial<Omit<IssueReadModel, 'links' | 'commentCount' | 'attachments'>>) {
|
Partial<
|
||||||
|
Omit<
|
||||||
|
IssueReadModel,
|
||||||
|
'links' | 'commentCount' | 'attachments' | 'watchers'
|
||||||
|
>
|
||||||
|
>) {
|
||||||
await upsertIfNewer(this.issues, id, version, fields, {
|
await upsertIfNewer(this.issues, id, version, fields, {
|
||||||
outwardLinks: [],
|
outwardLinks: [],
|
||||||
inwardLinks: [],
|
inwardLinks: [],
|
||||||
attachments: [],
|
attachments: [],
|
||||||
|
watchers: [],
|
||||||
commentCount: 0,
|
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) {
|
async addAttachment(issueId: string, attachment: AttachmentReadModel) {
|
||||||
await this.issues.updateOne(
|
await this.issues.updateOne(
|
||||||
{ _id: issueId, 'attachments.id': { $ne: attachment.id } },
|
{ _id: issueId, 'attachments.id': { $ne: attachment.id } },
|
||||||
|
|||||||
@@ -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, {
|
@Mutation(() => CommandResultGql, {
|
||||||
description:
|
description:
|
||||||
'Remove a file from a ticket (whoever uploaded it, or an owner/admin). Upload with the REST API.',
|
'Remove a file from a ticket (whoever uploaded it, or an owner/admin). Upload with the REST API.',
|
||||||
|
|||||||
@@ -143,6 +143,8 @@ export class IssueGql {
|
|||||||
@Field(() => String, { nullable: true }) dueDate!: string | null;
|
@Field(() => String, { nullable: true }) dueDate!: string | null;
|
||||||
@Field(() => [IssueLinkGql]) links!: IssueLinkGql[];
|
@Field(() => [IssueLinkGql]) links!: IssueLinkGql[];
|
||||||
@Field(() => [AttachmentGql]) attachments!: AttachmentGql[];
|
@Field(() => [AttachmentGql]) attachments!: AttachmentGql[];
|
||||||
|
@Field(() => [UserRefGql], { description: 'People following the ticket' })
|
||||||
|
watchers!: UserRefGql[];
|
||||||
@Field(() => Int) commentCount!: number;
|
@Field(() => Int) commentCount!: number;
|
||||||
@Field() createdAt!: string;
|
@Field() createdAt!: string;
|
||||||
@Field() updatedAt!: string;
|
@Field() updatedAt!: string;
|
||||||
|
|||||||
@@ -159,6 +159,8 @@ export class IssueDto {
|
|||||||
dueDate!: string | null;
|
dueDate!: string | null;
|
||||||
@ApiProperty({ type: [IssueLinkDto] }) links!: IssueLinkDto[];
|
@ApiProperty({ type: [IssueLinkDto] }) links!: IssueLinkDto[];
|
||||||
@ApiProperty({ type: [AttachmentDto] }) attachments!: AttachmentDto[];
|
@ApiProperty({ type: [AttachmentDto] }) attachments!: AttachmentDto[];
|
||||||
|
@ApiProperty({ type: [UserRefDto], description: 'People following it' })
|
||||||
|
watchers!: UserRefDto[];
|
||||||
@ApiProperty() commentCount!: number;
|
@ApiProperty() commentCount!: number;
|
||||||
@ApiProperty({ format: 'date-time' }) createdAt!: string;
|
@ApiProperty({ format: 'date-time' }) createdAt!: string;
|
||||||
@ApiProperty({ format: 'date-time' }) updatedAt!: string;
|
@ApiProperty({ format: 'date-time' }) updatedAt!: string;
|
||||||
|
|||||||
@@ -77,6 +77,36 @@ export class IssuesController {
|
|||||||
private readonly refs: Refs,
|
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')
|
@Get('me/issues')
|
||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
summary: 'My work: tickets across all my projects',
|
summary: 'My work: tickets across all my projects',
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ export const issuePageEvents = eventGroup({
|
|||||||
/** Uploaded under the new comment once it exists. */
|
/** Uploaded under the new comment once it exists. */
|
||||||
files?: File[];
|
files?: File[];
|
||||||
}>(),
|
}>(),
|
||||||
|
watchRequested: type<{ issueId: string; watching: boolean }>(),
|
||||||
attachRequested: type<{
|
attachRequested: type<{
|
||||||
issueId: string;
|
issueId: string;
|
||||||
files: File[];
|
files: File[];
|
||||||
|
|||||||
@@ -30,6 +30,8 @@ export class IssueFacade {
|
|||||||
readonly error = this.store.error;
|
readonly error = this.store.error;
|
||||||
/** Signed-in user's id (authors can edit their comments). */
|
/** Signed-in user's id (authors can edit their comments). */
|
||||||
readonly me = this.store.me;
|
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. */
|
/** Loads the ticket (by id or key, e.g. BE-3) and follows its changes. */
|
||||||
open(idOrKey: string): void {
|
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). */
|
/** Attaches files to the ticket (or under one of its comments). */
|
||||||
attach(files: File[], commentId: string | null = null): void {
|
attach(files: File[], commentId: string | null = null): void {
|
||||||
if (!files.length) return;
|
if (!files.length) return;
|
||||||
|
|||||||
@@ -60,6 +60,11 @@ export const IssueStore = signalStore(
|
|||||||
}),
|
}),
|
||||||
withComputed(({ issue }, auth = inject(AuthFacade)) => ({
|
withComputed(({ issue }, auth = inject(AuthFacade)) => ({
|
||||||
me: computed(() => auth.user()?.id ?? null),
|
me: computed(() => auth.user()?.id ?? null),
|
||||||
|
watching: computed(
|
||||||
|
() =>
|
||||||
|
!!auth.user() &&
|
||||||
|
(issue()?.watchers ?? []).some((w) => w.id === auth.user()?.id),
|
||||||
|
),
|
||||||
outwardLinks: computed(
|
outwardLinks: computed(
|
||||||
() => issue()?.links.filter((l) => l.direction === 'OUTWARD') ?? [],
|
() => issue()?.links.filter((l) => l.direction === 'OUTWARD') ?? [],
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -3,3 +3,4 @@ export * from './lib/models/models';
|
|||||||
export * from './lib/models/inputs';
|
export * from './lib/models/inputs';
|
||||||
export * from './lib/models/workflow';
|
export * from './lib/models/workflow';
|
||||||
export * from './lib/ports/gateways';
|
export * from './lib/ports/gateways';
|
||||||
|
export * from './lib/models/mentions';
|
||||||
|
|||||||
30
libs/web/tracker/domain/src/lib/models/mentions.spec.ts
Normal file
30
libs/web/tracker/domain/src/lib/models/mentions.spec.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
52
libs/web/tracker/domain/src/lib/models/mentions.ts
Normal file
52
libs/web/tracker/domain/src/lib/models/mentions.ts
Normal 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));
|
||||||
|
}
|
||||||
@@ -86,6 +86,8 @@ export interface Issue extends IssueSummary {
|
|||||||
dueDate: string | null;
|
dueDate: string | null;
|
||||||
links: IssueLink[];
|
links: IssueLink[];
|
||||||
attachments: Attachment[];
|
attachments: Attachment[];
|
||||||
|
/** People following the ticket. */
|
||||||
|
watchers: UserRef[];
|
||||||
allowedTransitions: string[];
|
allowedTransitions: string[];
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -87,6 +87,8 @@ export abstract class IssuesGateway {
|
|||||||
issueId: string,
|
issueId: string,
|
||||||
releaseId: string | null,
|
releaseId: string | null,
|
||||||
): Observable<CommandResult>;
|
): Observable<CommandResult>;
|
||||||
|
/** The signed-in person follows (or stops following) the ticket. */
|
||||||
|
abstract watch(issueId: string, watching: boolean): Observable<CommandResult>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export abstract class CommentsGateway {
|
export abstract class CommentsGateway {
|
||||||
|
|||||||
@@ -25,6 +25,8 @@ import {
|
|||||||
SearchIssuesDocument,
|
SearchIssuesDocument,
|
||||||
SetIssueReleaseDocument,
|
SetIssueReleaseDocument,
|
||||||
UnlinkIssuesDocument,
|
UnlinkIssuesDocument,
|
||||||
|
UnwatchIssueDocument,
|
||||||
|
WatchIssueDocument,
|
||||||
UpdateIssueDocument,
|
UpdateIssueDocument,
|
||||||
} from '../graphql/generated';
|
} from '../graphql/generated';
|
||||||
import { GraphqlOperations } from './graphql-operations';
|
import { GraphqlOperations } from './graphql-operations';
|
||||||
@@ -119,6 +121,16 @@ export class GraphqlIssuesGateway extends IssuesGateway {
|
|||||||
.pipe(map((d) => toCommandResult(d.unlinkIssues)));
|
.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(
|
setRelease(
|
||||||
issueId: string,
|
issueId: string,
|
||||||
releaseId: string | null,
|
releaseId: string | null,
|
||||||
|
|||||||
@@ -88,6 +88,9 @@ fragment IssueFields on Issue {
|
|||||||
issueId
|
issueId
|
||||||
issueKey
|
issueKey
|
||||||
}
|
}
|
||||||
|
watchers {
|
||||||
|
...UserRefFields
|
||||||
|
}
|
||||||
attachments {
|
attachments {
|
||||||
id
|
id
|
||||||
fileName
|
fileName
|
||||||
|
|||||||
@@ -248,6 +248,7 @@ export type IssueFieldsFragment = {
|
|||||||
issueId: string;
|
issueId: string;
|
||||||
issueKey: string;
|
issueKey: string;
|
||||||
}>;
|
}>;
|
||||||
|
watchers: Array<{ id: string; name: string }>;
|
||||||
attachments: Array<{
|
attachments: Array<{
|
||||||
id: string;
|
id: string;
|
||||||
fileName: string;
|
fileName: string;
|
||||||
@@ -362,6 +363,7 @@ export type IssueQuery = {
|
|||||||
issueId: string;
|
issueId: string;
|
||||||
issueKey: string;
|
issueKey: string;
|
||||||
}>;
|
}>;
|
||||||
|
watchers: Array<{ id: string; name: string }>;
|
||||||
attachments: Array<{
|
attachments: Array<{
|
||||||
id: string;
|
id: string;
|
||||||
fileName: string;
|
fileName: string;
|
||||||
@@ -451,6 +453,22 @@ export type UnlinkIssuesMutation = {
|
|||||||
unlinkIssues: { id: string; key: string | null; version: number };
|
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<{
|
export type SetIssueReleaseMutationVariables = Exact<{
|
||||||
issueId: string;
|
issueId: string;
|
||||||
releaseId?: string | null | undefined;
|
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',
|
kind: 'Field',
|
||||||
name: { kind: 'Name', value: 'attachments' },
|
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',
|
kind: 'Field',
|
||||||
name: { kind: 'Name', value: 'attachments' },
|
name: { kind: 'Name', value: 'attachments' },
|
||||||
@@ -2671,6 +2715,143 @@ export const UnlinkIssuesDocument = {
|
|||||||
UnlinkIssuesMutation,
|
UnlinkIssuesMutation,
|
||||||
UnlinkIssuesMutationVariables
|
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 = {
|
export const SetIssueReleaseDocument = {
|
||||||
kind: 'Document',
|
kind: 'Document',
|
||||||
definitions: [
|
definitions: [
|
||||||
|
|||||||
@@ -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) {
|
mutation SetIssueRelease($issueId: ID!, $releaseId: ID) {
|
||||||
setIssueRelease(issueId: $issueId, releaseId: $releaseId) {
|
setIssueRelease(issueId: $issueId, releaseId: $releaseId) {
|
||||||
...CommandResultFields
|
...CommandResultFields
|
||||||
|
|||||||
@@ -345,3 +345,39 @@ a.ts-key {
|
|||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
margin: 0.5rem 0;
|
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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -35,9 +35,13 @@ import {
|
|||||||
import {
|
import {
|
||||||
Attachment,
|
Attachment,
|
||||||
Comment,
|
Comment,
|
||||||
|
insertMention,
|
||||||
IssueLink,
|
IssueLink,
|
||||||
IssueLinkType,
|
IssueLinkType,
|
||||||
IssuePriority,
|
IssuePriority,
|
||||||
|
matches,
|
||||||
|
mentionQuery,
|
||||||
|
mentionSegments,
|
||||||
ReleaseStatus,
|
ReleaseStatus,
|
||||||
} from '@task-stream/web/tracker/domain';
|
} from '@task-stream/web/tracker/domain';
|
||||||
import {
|
import {
|
||||||
@@ -95,6 +99,20 @@ const MAX_FILE_BYTES = 25 * 1024 * 1024;
|
|||||||
</div>
|
</div>
|
||||||
<div class="ts-issue__title-row">
|
<div class="ts-issue__title-row">
|
||||||
<h1>{{ issue.title }}</h1>
|
<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()) {
|
@if (project.canContribute()) {
|
||||||
<pi-icon-button
|
<pi-icon-button
|
||||||
icon="edit"
|
icon="edit"
|
||||||
@@ -287,7 +305,19 @@ const MAX_FILE_BYTES = 25 * 1024 * 1024;
|
|||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
</div>
|
</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) {
|
@if (filesOf(comment).length) {
|
||||||
<div class="ts-attachments">
|
<div class="ts-attachments">
|
||||||
@for (file of filesOf(comment); track file.id) {
|
@for (file of filesOf(comment); track file.id) {
|
||||||
@@ -308,9 +338,29 @@ const MAX_FILE_BYTES = 25 * 1024 * 1024;
|
|||||||
novalidate
|
novalidate
|
||||||
>
|
>
|
||||||
<pi-textarea
|
<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"
|
[(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
|
<pi-file-upload
|
||||||
label="Adjuntar al comentario"
|
label="Adjuntar al comentario"
|
||||||
[multiple]="true"
|
[multiple]="true"
|
||||||
@@ -576,6 +626,19 @@ export class IssuePage {
|
|||||||
protected readonly linkTarget = signal('');
|
protected readonly linkTarget = signal('');
|
||||||
protected readonly commentBody = signal('');
|
protected readonly commentBody = signal('');
|
||||||
protected readonly commentFiles = signal<File[]>([]);
|
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 pendingFiles = signal<File[]>([]);
|
||||||
protected readonly maxFileBytes = MAX_FILE_BYTES;
|
protected readonly maxFileBytes = MAX_FILE_BYTES;
|
||||||
|
|
||||||
@@ -679,6 +742,10 @@ export class IssuePage {
|
|||||||
this.commentFiles.set([]);
|
this.commentFiles.set([]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
protected mention(user: { id: string; name: string }): void {
|
||||||
|
this.commentBody.update((text) => insertMention(text, user));
|
||||||
|
}
|
||||||
|
|
||||||
protected attach(): void {
|
protected attach(): void {
|
||||||
this.facade.attach(this.pendingFiles());
|
this.facade.attach(this.pendingFiles());
|
||||||
this.pendingFiles.set([]);
|
this.pendingFiles.set([]);
|
||||||
|
|||||||
@@ -190,7 +190,7 @@ const ICON: Record<NotificationKind, string> = {
|
|||||||
height: 0.5rem;
|
height: 0.5rem;
|
||||||
margin-top: 0.5rem;
|
margin-top: 0.5rem;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
background: #a347e1;
|
background: var(--ts-brand, #a347e1);
|
||||||
}
|
}
|
||||||
.ts-inbox__empty {
|
.ts-inbox__empty {
|
||||||
opacity: 0.7;
|
opacity: 0.7;
|
||||||
|
|||||||
Reference in New Issue
Block a user