Compare commits

..

6 Commits

Author SHA1 Message Date
034704e46f feat: alerts open bugs, and repeats of the same problem go to that bug
Owners and admins turn on a secret URL per project (Settings →
Integrations), POST /api/webhooks/alerts/{project}/{token}, that Sentry,
Uptime Kuma, Prometheus Alertmanager, Grafana or any tool sending JSON
with a title can call. A new problem opens a bug as TaskStream (label
«alerta», chosen type and priority); while that bug is open, the same
fingerprint goes to it (a comment at most every 30 minutes) and
«resolved» is said on it; once it is closed, a new bug names the old
one. Alerts of a project are handled one at a time, so two at once open
one bug, and a bug not yet projected still counts as open. Unknown
project, alerts off or a bad token all answer 403.

TaskStream may now comment as the system actor, like it already opened
tickets for public proposals. Stored in Mongo (alert_integrations,
alert_traces), not in the event store.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 12:36:55 +02:00
42a21412d9 feat(web): «Deshacer» after moving, reassigning or trashing a ticket
A toast offers to undo for seven seconds (or Ctrl/⌘ Z outside a field)
by running the opposite command: the card back to its column or the
ticket to its status (the workflow may refuse it), the previous
assignee, or the ticket back from the trash. The undo itself only says
«Deshecho». On the board, from the card's assignee; on the ticket page,
status, assignee and trash, which no longer asks for confirmation.

The toast is the app's own (UndoToastComponent): pi-snack-bar cannot
tell its button from the timeout.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 11:43:54 +02:00
70e4fbd399 feat: comments marked as decisions, on top of the ticket and searchable
Whoever can change the ticket flags a comment as a decision (new event
CommentDecisionMarkedEvent on the comment). The ticket page lists its
decisions above the description, with a link to the comment in the
conversation (`?comment=<id>` scrolls to it and points at it), and the
search palette finds them by their text across my projects.

API: markDecision / PUT-DELETE /api/comments/{id}/decision; Issue.decisions
and GET /api/issues/{issue}/decisions; decisions(projectId?, search) and
GET /api/projects/{project}/decisions, GET /api/me/decisions. MCP:
mark_decision, search_decisions, and get_issue includes them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 11:32:07 +02:00
8f83fdefb6 fix(web): the ticket page fits a phone again
Its main column was a grid without columns, so it grew to the width of
its widest form field (650 px on a 390 px screen) and cut the text.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 11:23:27 +02:00
dfa42a8a1a feat: pinned tickets, at hand on the home page and in search
Each person pins up to 20 tickets from the ticket's page («Fijar»). They
show as a strip under the projects on the home page and in the search
palette before typing. Private and without history, like the views of
«my work» (Mongo `pinned_issues`); tickets in the trash or out of reach
are left out, and when the list is full the unreadable ones are dropped
first.

API: GET /api/me/pins, PUT/DELETE /api/issues/{issue}/pin; GraphQL
myPins, pinIssue, unpinIssue and Issue.pinned.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 10:15:28 +02:00
423f82c1a3 feat(web): priority, estimate and assignee change right on the board card
The card's title is now the link (stretched over the card), and the three
chips carry an invisible native select each, so they work by keyboard,
on a phone and with a screen reader without opening the ticket. Dragging
still works from anywhere on the card.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 09:41:23 +02:00
85 changed files with 7903 additions and 212 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -45,6 +45,27 @@ type Aging {
thresholdDays: Float
}
"""Alerts → bugs: the secret URL monitoring tools call to open bugs"""
type AlertIntegration {
enabled: Boolean!
priority: IssuePriority!
"""Ticket type it opens; null: the project's first bug type"""
typeId: ID
"""
Path of the URL on this API, secret token included; only owners and admins see it
"""
webhookPath: String
}
input AlertSettingsInput {
priority: IssuePriority
"""Ticket type to open; null: the project's first bug type"""
typeId: ID
}
type AreaOption {
archived: Boolean!
id: String!
@@ -270,6 +291,13 @@ type Comment {
"""Empty on a deleted comment kept for its replies"""
body: String!
createdAt: String!
decidedAt: String
"""Who marked it as a decision"""
decidedBy: UserRef
"""Marked as a decision of the ticket (shown above it)"""
decision: Boolean!
"""Deleted, but kept (without its text) while it has replies"""
deleted: Boolean!
@@ -475,6 +503,12 @@ type DashboardShare {
token: String!
}
"""A comment marked as a decision, with its ticket"""
type Decision {
comment: Comment!
issue: IssueParent!
}
"""A commit, branch or pull request that mentions the ticket"""
type DevLink {
at: String!
@@ -640,6 +674,9 @@ type Issue {
"""Values of the project's custom fields (only those set)"""
customFields: [CustomFieldValue!]!
"""Its comments marked as decisions, oldest first"""
decisions: [Comment!]!
description: String
"""Commits, branches and pull requests that mention it"""
@@ -662,6 +699,9 @@ type Issue {
"""The epic or ticket this one breaks down"""
parent: IssueParent
"""Whether I pinned it (false without a token)"""
pinned: Boolean!
priority: IssuePriority!
projectId: ID!
projectKey: String!
@@ -958,6 +998,9 @@ type Mutation {
deleteSprint(sprintId: ID!): CommandResult!
deleteWebhook(webhookId: ID!): Boolean!
"""Turn it off; the bugs it opened stay"""
disableAlertIntegration(projectId: ID!): Boolean!
"""
A new ticket like this one: what it says, not where it stands (the key comes in the result)
"""
@@ -970,9 +1013,19 @@ type Mutation {
"""Relate tickets, e.g. FE DEPENDS_ON BE, QA TESTS FE"""
linkIssues(input: LinkIssuesInput!): CommandResult!
"""
Mark a comment as a decision of its ticket, or unmark it (on: false). Whoever can change the ticket
"""
markDecision(commentId: ID!, on: Boolean! = true): CommandResult!
"""Mark these as read, or all of them when `ids` is omitted"""
markNotificationsRead(ids: [ID!]): Boolean!
"""
Pin a ticket: keep it at hand on my home page and in search (private, at most 20)
"""
pinIssue(issueId: ID!): Boolean!
"""
Ship the release. Fails while it has unfinished tickets unless force = true.
"""
@@ -1000,9 +1053,17 @@ type Mutation {
"""My calendar URL stops working"""
revokeCalendarFeed: Boolean!
"""A new secret URL; the old one stops working at once"""
rotateAlertToken(projectId: ID!): AlertIntegration!
"""A new secret for a repository; the old one stops working"""
rotateGitRepositorySecret(projectId: ID!, repositoryId: ID!): GitRepository!
"""
Turn alerts → bugs on, or change the type and priority it opens (the URL stays)
"""
saveAlertIntegration(input: AlertSettingsInput!, projectId: ID!): AlertIntegration!
"""Save the current board filters under a name (members of the project)"""
saveFilter(input: SaveFilterInput!): ID!
@@ -1046,6 +1107,9 @@ type Mutation {
unarchiveProject(projectId: ID!): CommandResult!
unlinkIssues(input: LinkIssuesInput!): CommandResult!
"""Unpin a ticket"""
unpinIssue(issueId: ID!): Boolean!
"""The link stops working"""
unshareDashboard(projectId: ID!): Boolean!
@@ -1475,11 +1539,17 @@ type Query {
limit: Int! = 50
projectId: ID!
): [Activity!]!
alertIntegration(projectId: ID!): AlertIntegration!
automations(projectId: ID!): [Automation!]!
"""The project's link for clients, if any (owners and admins)"""
dashboardShare(projectId: ID!): DashboardShare
"""
Decisions by their text, most recent first: of a project, or of all my projects without one
"""
decisions(limit: Int! = 20, projectId: ID, search: String): [Decision!]!
"""
A project's epics: top-level tickets that group others, with the progress of those
"""
@@ -1532,6 +1602,11 @@ type Query {
"""My notifications, newest first, and how many are unread"""
myNotifications(limit: Int! = 30, unreadOnly: Boolean! = false): MyNotifications!
"""
My pinned tickets, most recently pinned first (trashed or out of reach left out)
"""
myPins: [Issue!]!
"""Projects where I am a member"""
myProjects: [Project!]!

File diff suppressed because it is too large Load Diff

View File

@@ -254,7 +254,7 @@ export class IssuesResolver {
@Tool({
name: 'get_issue',
description:
'A ticket in full: description, parent, checklist (with item ids), links, commits and pull requests, followers, allowed next statuses, and optionally its comments and history.',
'A ticket in full: description, parent, checklist (with item ids), links, commits and pull requests, followers, allowed next statuses, the comments marked as decisions, and optionally its comments and history.',
paramsSchema: z.object({
issue: issueRef,
withComments: z.boolean().default(true),
@@ -289,8 +289,20 @@ export class IssuesResolver {
emoji: r.emoji,
by: r.users.map((u) => u.name),
})),
decision: c.decision || undefined,
}))
: undefined;
const decisions = (
await this.api.data(
client.GET('/api/issues/{issue}/decisions', { params: { path } }),
)
).map((c) => ({
commentId: c.id,
by: c.author.name,
at: c.createdAt,
markedBy: c.decidedBy?.name ?? null,
body: c.body,
}));
const history = params.withHistory
? (
await this.api.data(
@@ -322,6 +334,7 @@ export class IssuesResolver {
attachments: issue.attachments.length,
trashedAt: issue.trashedAt,
createdAt: issue.createdAt,
decisions,
comments,
history,
};
@@ -734,6 +747,67 @@ export class IssuesResolver {
);
}
@Tool({
name: 'mark_decision',
description:
'Mark a comment as a decision of its ticket (what was agreed), or unmark it (on: false). Whoever can change the ticket. Comment ids come with get_issue.',
paramsSchema: z.object({
commentId: z.string().min(1),
on: z.boolean().default(true),
}),
annotations: { ...WRITE, idempotentHint: true },
})
markDecision(
{ commentId, on }: { commentId: string; on: boolean },
ctx: McpContext,
) {
const client = this.api.as(ctx);
const options = { params: { path: { commentId } } };
return toolResult(() =>
this.api.data(
on
? client.PUT('/api/comments/{commentId}/decision', options)
: client.DELETE('/api/comments/{commentId}/decision', options),
),
);
}
@Tool({
name: 'search_decisions',
description:
'What was decided, by words in the decision: comments marked as decisions, most recent first, with their ticket. Of one project, or of all my projects without one.',
paramsSchema: z.object({
project: projectRef.optional(),
search: z.string().max(200).optional(),
limit: limit(100, 20),
}),
annotations: READ,
})
searchDecisions(
params: { project?: string; search?: string; limit: number },
ctx: McpContext,
) {
return toolResult(async () => {
const client = this.api.as(ctx);
const query = { search: params.search, limit: params.limit };
const found = await this.api.data(
params.project
? client.GET('/api/projects/{project}/decisions', {
params: { path: { project: params.project }, query },
})
: client.GET('/api/me/decisions', { params: { query } }),
);
return found.map((d) => ({
issue: d.issue.key,
title: d.issue.title,
commentId: d.comment.id,
by: d.comment.author.name,
at: d.comment.createdAt,
body: d.comment.body,
}));
});
}
@Tool({
name: 'vote_issue',
description:

View File

@@ -2,47 +2,49 @@
## Qué resuelve
| Necesidad | Cómo se resuelve |
| --------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| 🐛 Bugs | Tickets de tipo `BUG` con prioridad, área y etiquetas |
| 🌍 Públicos | Un proyecto público se puede leer sin iniciar sesión (web, GraphQL y REST): tablero, tickets, comentarios, releases, actividad y estadísticas; «Explorar» los lista |
| 🗑️ Borrar | El propietario borra un proyecto confirmando su clave; tickets, comentarios, releases y enlaces desde otros proyectos se borran en cascada (sagas) y la clave queda libre |
| 🧩 Configuración | Cada proyecto tiene sus estados (con categoría, color y motivo obligatorio), transiciones, tipos de ticket y áreas; lo usado se archiva, no se borra |
| 🎨 Cambios de UI | Tipo `UI_CHANGE` con enlace al frame de Figma (validado contra `figma.com`) |
| ⚙️ Features | Tipo `FEATURE` siguiendo el workflow completo |
| 🧪 QA | Por defecto, `READY_FOR_QA → IN_QA → DONE / QA_FAILED`; fallar QA exige un motivo |
| 🚀 Releases | Versiones a las que se asignan tickets; no se publican con tickets sin terminar (salvo forzando) |
| 📝 Notas de versión | Borrador en Markdown con los tickets terminados por tipo, editable y copiable, también tras publicar |
| 👥 Asignaciones | Solo a miembros del proyecto; panel de carga por persona |
| 💬 Comentarios | Decisiones y explicaciones dentro del ticket, con formato (editor visual, guardado en Markdown), capturas pegadas, respuestas, reacciones (👍 🎉…) y `@menciones`; el autor edita, el admin modera |
| 🔎 Vistas | El tablero se filtra por texto, persona (o sin asignar), tipo, prioridad, área y release; los filtros se guardan como vistas propias o compartidas con el proyecto |
| 📋 Lista | Todos los tickets en filas: ordenar por cualquier columna, filtrar, cambiar estado, prioridad o persona sin abrirlos, editar en bloque y duplicar |
| 🚦 WIP y carriles | Límite de trabajo en curso por estado (la columna se marca al pasarse) y tablero agrupable por persona, épica, prioridad o tipo |
| 🎲 Estimación | Puntos por ticket (escala 0-1-2-3-5-8-13-21) en la ficha, las tarjetas, las columnas y el backlog; velocidad por sprint y aviso si un sprint planificado supera la media |
| 🏃 Sprints | Iteraciones con fechas y objetivo, una en curso por proyecto, con burndown (tickets o puntos) y traspaso de lo pendiente al cerrar |
| 📅 Calendario | Los sprints mes a mes como franjas, con las releases y los vencimientos de cada día; se planifica un sprint pulsando su primer día y todo se descarga en .ics |
| 📋 Backlog | Lo pendiente de empezar, en el orden en que se va a coger; se reordena arrastrando y el tablero sigue ese orden |
| 🗑️ Papelera | Borrar un ticket lo manda a la papelera (quien lo abrió o un admin); se recupera durante 30 días y luego se borra del todo |
| ☑️ En bloque | Selecciona varios tickets del backlog y cámbiales a la vez estado, persona, sprint, prioridad o etiqueta |
| ⌨️ Atajos y búsqueda | Ctrl/⌘ K o / busca tickets y proyectos de todos tus proyectos; `g b`, `g k`, `g s`, `g r` navegan, `c` crea, `e` edita, `?` los lista |
| 🗺️ Hoja de ruta | Las épicas en el tiempo (fecha de inicio y límite), con los sprints y las releases como referencias y flechas entre las que dependen unas de otras |
| 🗳️ Propuestas y votos | Un proyecto público puede abrir un formulario para que cualquiera, sin cuenta, reporte un error o proponga una idea; quien tenga sesión vota lo que quiere |
| 🌐 Web del colectivo | La página de cada proyecto en pireon.dev enseña su hoja de ruta, versiones, sprint y tickets para recién llegados (`good-first-issue`) |
| 🧩 Desglose | Épicas → tickets → subtareas (tres niveles), con su progreso; y una checklist dentro de cada ticket para los pasos pequeños |
| 👀 Seguir | Cualquier miembro sigue los tickets que le interesan y los ve juntos en «Que sigo»; quien lo abre, lo tiene asignado o comenta, lo sigue solo |
| 🔔 Avisos | Campana con lo que te concierne (asignaciones, cambios de estado, QA fallido, comentarios, respuestas), en vivo y opcionalmente por correo |
| ⏰ Recordatorios | Aviso (y correo, si se quiere) el día antes, el mismo día y al vencer la fecha límite, a quien lo tiene y a quien lo sigue; las tarjetas marcan lo que vence |
| 📎 Adjuntos | Capturas, logs y vídeos en el ticket o bajo un comentario; arrastrar, elegir o pegar con Ctrl+V; miniaturas de las imágenes |
| 🔗 Relaciones | `BLOCKS`, `DEPENDS_ON`, `TESTS`, `DUPLICATES`, `RELATES_TO`, también entre proyectos (FE ↔ BE ↔ QA); lo que espera a otro ticket abierto sale marcado como «Bloqueado» |
| 🌿 Git | Webhook para GitHub, Gitea o GitLab: commits, ramas y pull requests que nombran un ticket (WEB-12) aparecen en su ficha |
| 🧾 Plantillas | Cada tipo de ticket puede traer una descripción inicial y una checklist que se aplican al crearlo |
| 🤖 Automatizaciones | Reglas «cuando… si… entonces…»: al crear, mover, asignar, comentar o fusionar una PR, mover, asignar, etiquetar, priorizar, comentar o meter en el sprint |
| 📡 Webhooks | Un POST firmado (HMAC-SHA256) a otro sistema cuando pasa algo: tickets, comentarios, releases, sprints; con prueba y último envío |
| 📦 Importar/exportar | Todos los tickets a CSV; importar un CSV propio o una exportación de Jira, con vista previa de lo que se va a crear |
| 🏠 Inicio | «Mi trabajo»: mis tickets en todos los proyectos, por categoría (QA fallidos primero), lo que espera QA, próximas releases e «Ir a…» por clave |
| 📊 Seguimiento | Panel con contadores, flujo por estado, el equipo con sus tickets abiertos (enlaza a su tablero), feed de actividad e historial por ticket |
| 📈 Estadísticas | Creados vs. resueltos, flujo acumulado, burnup de releases, tiempos de ciclo y entrega, tasa de fallo en QA y resueltos por persona |
| ⚡ Tiempo real | Tablero, comentarios, actividad, releases y membresías se actualizan solos (GraphQL subscriptions) |
| Necesidad | Cómo se resuelve |
| --------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| 🐛 Bugs | Tickets de tipo `BUG` con prioridad, área y etiquetas |
| 🌍 Públicos | Un proyecto público se puede leer sin iniciar sesión (web, GraphQL y REST): tablero, tickets, comentarios, releases, actividad y estadísticas; «Explorar» los lista |
| 🗑️ Borrar | El propietario borra un proyecto confirmando su clave; tickets, comentarios, releases y enlaces desde otros proyectos se borran en cascada (sagas) y la clave queda libre |
| 🧩 Configuración | Cada proyecto tiene sus estados (con categoría, color y motivo obligatorio), transiciones, tipos de ticket y áreas; lo usado se archiva, no se borra |
| 🎨 Cambios de UI | Tipo `UI_CHANGE` con enlace al frame de Figma (validado contra `figma.com`) |
| ⚙️ Features | Tipo `FEATURE` siguiendo el workflow completo |
| 🧪 QA | Por defecto, `READY_FOR_QA → IN_QA → DONE / QA_FAILED`; fallar QA exige un motivo |
| 🚀 Releases | Versiones a las que se asignan tickets; no se publican con tickets sin terminar (salvo forzando) |
| 📝 Notas de versión | Borrador en Markdown con los tickets terminados por tipo, editable y copiable, también tras publicar |
| 👥 Asignaciones | Solo a miembros del proyecto; panel de carga por persona |
| 💬 Comentarios | Decisiones y explicaciones dentro del ticket, con formato (editor visual, guardado en Markdown), capturas pegadas, respuestas, reacciones (👍 🎉…), decisiones (un comentario marcado sale encima del ticket y se encuentra al buscar) y `@menciones`; el autor edita, el admin modera |
| 🔎 Vistas | El tablero se filtra por texto, persona (o sin asignar), tipo, prioridad, área y release; los filtros se guardan como vistas propias o compartidas con el proyecto |
| 📋 Lista | Todos los tickets en filas: ordenar por cualquier columna, filtrar, cambiar estado, prioridad o persona sin abrirlos, editar en bloque y duplicar |
| ↩️ Deshacer | Mover, reasignar o mandar a la papelera ofrece «Deshacer» unos segundos (también Ctrl/⌘ Z), con la orden contraria |
| 🚦 WIP y carriles | Límite de trabajo en curso por estado (la columna se marca al pasarse) y tablero agrupable por persona, épica, prioridad o tipo |
| 🎲 Estimación | Puntos por ticket (escala 0-1-2-3-5-8-13-21) en la ficha, las tarjetas, las columnas y el backlog; velocidad por sprint y aviso si un sprint planificado supera la media |
| 🏃 Sprints | Iteraciones con fechas y objetivo, una en curso por proyecto, con burndown (tickets o puntos) y traspaso de lo pendiente al cerrar |
| 📅 Calendario | Los sprints mes a mes como franjas, con las releases y los vencimientos de cada día; se planifica un sprint pulsando su primer día y todo se descarga en .ics |
| 📋 Backlog | Lo pendiente de empezar, en el orden en que se va a coger; se reordena arrastrando y el tablero sigue ese orden |
| 🗑️ Papelera | Borrar un ticket lo manda a la papelera (quien lo abrió o un admin); se recupera durante 30 días y luego se borra del todo |
| ☑️ En bloque | Selecciona varios tickets del backlog y cámbiales a la vez estado, persona, sprint, prioridad o etiqueta |
| ⌨️ Atajos y búsqueda | Ctrl/⌘ K o / busca tickets, decisiones y proyectos de todos tus proyectos (antes de escribir, tus tickets fijados); `g b`, `g k`, `g s`, `g r` navegan, `c` crea, `e` edita, `?` los lista |
| 🗺️ Hoja de ruta | Las épicas en el tiempo (fecha de inicio y límite), con los sprints y las releases como referencias y flechas entre las que dependen unas de otras |
| 🗳️ Propuestas y votos | Un proyecto público puede abrir un formulario para que cualquiera, sin cuenta, reporte un error o proponga una idea; quien tenga sesión vota lo que quiere |
| 🌐 Web del colectivo | La página de cada proyecto en pireon.dev enseña su hoja de ruta, versiones, sprint y tickets para recién llegados (`good-first-issue`) |
| 🧩 Desglose | Épicas → tickets → subtareas (tres niveles), con su progreso; y una checklist dentro de cada ticket para los pasos pequeños |
| 👀 Seguir | Cualquier miembro sigue los tickets que le interesan y los ve juntos en «Que sigo»; quien lo abre, lo tiene asignado o comenta, lo sigue solo |
| 🔔 Avisos | Campana con lo que te concierne (asignaciones, cambios de estado, QA fallido, comentarios, respuestas), en vivo y opcionalmente por correo |
| ⏰ Recordatorios | Aviso (y correo, si se quiere) el día antes, el mismo día y al vencer la fecha límite, a quien lo tiene y a quien lo sigue; las tarjetas marcan lo que vence |
| 📎 Adjuntos | Capturas, logs y vídeos en el ticket o bajo un comentario; arrastrar, elegir o pegar con Ctrl+V; miniaturas de las imágenes |
| 🔗 Relaciones | `BLOCKS`, `DEPENDS_ON`, `TESTS`, `DUPLICATES`, `RELATES_TO`, también entre proyectos (FE ↔ BE ↔ QA); lo que espera a otro ticket abierto sale marcado como «Bloqueado» |
| 🌿 Git | Webhook para GitHub, Gitea o GitLab: commits, ramas y pull requests que nombran un ticket (WEB-12) aparecen en su ficha |
| 🚨 Alertas → bugs | Sentry, Uptime Kuma, Grafana o cualquier herramienta abren bugs por una URL secreta; el mismo problema repetido va al bug abierto, y «resuelta» se dice en él |
| 🧾 Plantillas | Cada tipo de ticket puede traer una descripción inicial y una checklist que se aplican al crearlo |
| 🤖 Automatizaciones | Reglas «cuando… si… entonces…»: al crear, mover, asignar, comentar o fusionar una PR, mover, asignar, etiquetar, priorizar, comentar o meter en el sprint |
| 📡 Webhooks | Un POST firmado (HMAC-SHA256) a otro sistema cuando pasa algo: tickets, comentarios, releases, sprints; con prueba y último envío |
| 📦 Importar/exportar | Todos los tickets a CSV; importar un CSV propio o una exportación de Jira, con vista previa de lo que se va a crear |
| 🏠 Inicio | «Mi trabajo»: mis tickets en todos los proyectos, por categoría (QA fallidos primero), lo que espera QA, próximas releases e «Ir a…» por clave |
| 📊 Seguimiento | Panel con contadores, flujo por estado, el equipo con sus tickets abiertos (enlaza a su tablero), feed de actividad e historial por ticket |
| 📈 Estadísticas | Creados vs. resueltos, flujo acumulado, burnup de releases, tiempos de ciclo y entrega, tasa de fallo en QA y resueltos por persona |
| ⚡ Tiempo real | Tablero, comentarios, actividad, releases y membresías se actualizan solos (GraphQL subscriptions) |
Los **usuarios no se gestionan aquí**: vienen del servidor de identidad **Pireon**
([Swagger](https://oauth.pireon.dev/api/documentation)). TaskStream gestiona proyectos, membresías y roles por

View File

@@ -238,6 +238,12 @@ stateDiagram-v2
- **Reacciones**: 👍 👎 😄 🎉 😕 ❤️ 🚀 👀 en los comentarios (`reactToComment(commentId, emoji, on)`,
`POST /api/comments/{id}/reactions`); los miembros, también los lectores. Cada comentario da `reactions` con quién
eligió cada una. No generan actividad ni avisos.
- **Decisiones**: quien puede cambiar el ticket marca un comentario como decisión (`markDecision(commentId, on)`,
`PUT/DELETE /api/comments/{id}/decision`; evento `CommentDecisionMarkedEvent`). El comentario da `decision`,
`decidedBy` y `decidedAt`; el ticket, `decisions` (más antiguas primero, `GET /api/issues/{issue}/decisions`), que la
web muestra encima de la descripción. Se buscan por su texto: `decisions(projectId?, search, limit)`,
`GET /api/projects/{project}/decisions` y, de todos mis proyectos, `GET /api/me/decisions` (más recientes primero; sin
las de la papelera y, para un invitado, solo las de sus tickets).
- **Bloqueos**: un ticket espera a otro si ese otro lo bloquea (`BLOCKS`) o si él depende de ese otro (`DEPENDS_ON`),
mientras el otro no esté hecho ni cancelado. `blockedBy` lo calcula al leer (los enlaces no guardan el estado del
otro ticket), con una sola consulta por tablero o página.
@@ -259,6 +265,20 @@ stateDiagram-v2
encima y el de quien queda debajo, leídos del event store; si entretanto el orden cambió, la API lo rechaza y la web
recarga. Un ticket que nunca se movió ocupa su orden de creación (`IssueRankedEvent` no existe para él). Las
columnas del tablero y `issues(filter: { sort: RANK })` siguen este orden.
- **Alertas → bugs**: un owner o admin activa en Integraciones una URL secreta por proyecto,
`POST /api/webhooks/alerts/{proyecto}/{token}` (el token es el secreto: Sentry o Uptime Kuma solo dejan pegar una
URL). Entiende Sentry (integración interna, cabecera `sentry-hook-resource`, o el plugin de webhooks), Uptime Kuma,
Prometheus Alertmanager y Grafana; cualquier otra herramienta manda JSON con `title` y, si quiere, `fingerprint`,
`description`, `url` y `resolved`. Cada aviso tiene una huella (el id del problema en Sentry, el monitor en Uptime
Kuma, el `groupKey` en Alertmanager o `fingerprint`): la primera vez TaskStream abre un bug (actor `system`, etiqueta
`alerta`, el tipo y la prioridad elegidos; sin tipo, el primero con icono de bug); mientras ese bug siga abierto, las
repeticiones van a él (un comentario como mucho cada 30 minutos, con cuántas van) y «resuelta» se dice en él; si ya
está cerrado, abre otro que nombra el anterior. Responde `{ action: created | repeated | resolved | ignored, issueKey
}`; proyecto desconocido, alertas apagadas o token malo responden igual: 403. Las alertas de un proyecto se atienden
de una en una, así que dos iguales a la vez abren un solo bug. Configuración: `alertIntegration`,
`saveAlertIntegration(input: { typeId, priority })`, `rotateAlertToken`, `disableAlertIntegration`;
`GET/PUT/DELETE /api/projects/{project}/alert-integration` y `POST …/token`. Vive en MongoDB (`alert_integrations`,
y `alert_traces` con qué bug abrió cada huella), no en el event store.
- **Integración con Git**: un owner o admin añade en la configuración del proyecto sus repositorios (`api`, `web`,
`admin`…), cada uno con su propio secreto. Todos llaman a la misma URL, `POST /api/webhooks/git/{proyecto}`, con los
eventos _push_ y _pull/merge request_; GitHub y Gitea firman el cuerpo con el secreto (HMAC-SHA256) y GitLab lo envía
@@ -396,7 +416,11 @@ eventos `IssueWatchedEvent`/`IssueUnwatchedEvent`), y quien lo abre, quien lo ti
sigue un ticket (`WATCHED`) sabe cuándo cambia de estado, se asigna, se comenta, cambia su prioridad o su fecha límite,
o entra o sale de un sprint; las ediciones menores (título, etiquetas…) no avisan. «Los que sigo»: `watching` en
`myIssues` / `GET /api/me/issues` y en la búsqueda de un proyecto (`issues(filter: { watching: true })`,
`?watching=true`; sin sesión, ninguno). El id es
`?watching=true`; sin sesión, ninguno). **Fijar**: cada persona fija hasta 20 tickets para tenerlos a mano en el
inicio y en la búsqueda (`pinIssue`/`unpinIssue`, `myPins`, `Issue.pinned`; `GET /api/me/pins`,
`PUT/DELETE /api/issues/{issue}/pin`). Son privados y sin historial (colección `pinned_issues`, como las vistas de «Mi
trabajo»); los que están en la papelera o fuera de su alcance no se listan, y al llegar a 20 se olvidan primero los
que ya no puede leer. El id es
`<agregado>:<versión>:<persona>`, así que reentregar un evento no duplica avisos. Llegan en vivo por la subscription
`notificationAdded` y, si la instalación tiene `SMTP_URL`, también por correo a quien lo active (`email`, desactivado
por defecto); un correo que falla se registra y no bloquea el stream.
@@ -507,17 +531,17 @@ que funcionan también sin token.
Todas devuelven `CommandResult { id, key, version }`.
| Ámbito | Mutations |
| ----------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Proyectos | `createProject`, `updateProject`, `updateProjectSettings`, `deleteProject(confirmKey)`, `addProjectMember`, `changeProjectMemberRole`, `removeProjectMember` |
| Tickets | `createIssue`, `updateIssue`, `changeIssueStatus`, `assignIssue`, `linkIssues`, `unlinkIssues`, `setIssueRelease`, `setIssueParent`, `rankIssue`, `addChecklistItem`, `updateChecklistItem`, `removeChecklistItem`, `watchIssue`, `unwatchIssue`, `removeAttachment` (subir: por REST), `duplicateIssue`, `voteIssue` |
| Comentarios | `addComment(replyTo)`, `editComment`, `deleteComment`, `reactToComment` |
| Webhooks | `createWebhook` (devuelve el secreto), `updateWebhook`, `deleteWebhook`, `testWebhook` |
| Git | `addGitRepository` (devuelve su secreto), `rotateGitRepositorySecret`, `removeGitRepository` |
| Sprints | `createSprint`, `updateSprint`, `startSprint`, `closeSprint(carryOverToSprintId)`, `deleteSprint`, `setIssueSprint` |
| Vistas | `saveFilter(input)` (devuelve el id), `deleteSavedFilter(id)`, `GET/POST /api/me/views`, `DELETE /api/me/views/{id}` (mis vistas de «Mi trabajo») |
| Avisos | `markNotificationsRead(ids)`, `updateNotificationSettings(email)` |
| Releases | `createRelease`, `updateRelease`, `publishRelease(force)`, `writeReleaseNotes`, `archiveRelease` |
| Ámbito | Mutations |
| ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| Proyectos | `createProject`, `updateProject`, `updateProjectSettings`, `deleteProject(confirmKey)`, `addProjectMember`, `changeProjectMemberRole`, `removeProjectMember` |
| Tickets | `createIssue`, `updateIssue`, `changeIssueStatus`, `assignIssue`, `linkIssues`, `unlinkIssues`, `setIssueRelease`, `setIssueParent`, `rankIssue`, `addChecklistItem`, `updateChecklistItem`, `removeChecklistItem`, `watchIssue`, `unwatchIssue`, `pinIssue`, `unpinIssue` (devuelven `true`), `removeAttachment` (subir: por REST), `duplicateIssue`, `voteIssue` |
| Comentarios | `addComment(replyTo)`, `editComment`, `deleteComment`, `reactToComment`, `markDecision(commentId, on)`; consulta `decisions(projectId, search)` |
| Webhooks | `createWebhook` (devuelve el secreto), `updateWebhook`, `deleteWebhook`, `testWebhook` |
| Git | `addGitRepository` (devuelve su secreto), `rotateGitRepositorySecret`, `removeGitRepository` |
| Sprints | `createSprint`, `updateSprint`, `startSprint`, `closeSprint(carryOverToSprintId)`, `deleteSprint`, `setIssueSprint` |
| Vistas | `saveFilter(input)` (devuelve el id), `deleteSavedFilter(id)`, `GET/POST /api/me/views`, `DELETE /api/me/views/{id}` (mis vistas de «Mi trabajo») |
| Avisos | `markNotificationsRead(ids)`, `updateNotificationSettings(email)` |
| Releases | `createRelease`, `updateRelease`, `publishRelease(force)`, `writeReleaseNotes`, `archiveRelease` |
### Subscriptions
@@ -714,24 +738,25 @@ Lo que cada usuario puede ver o cambiar depende de su rol en cada proyecto, como
Proyectos y tickets se pueden indicar por **id o por clave** (`WEB`, `WEB-12`).
| Recurso | Rutas |
| ----------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Usuarios | `GET /api/users/me`, `GET /api/users?search=`, `GET /api/users/{id}` |
| Proyectos | `GET/POST /api/projects`, `GET/PATCH/DELETE /api/projects/{project}` (`DELETE …?confirm=CLAVE`), `POST …/archive`, `POST …/unarchive`, `PUT …/settings`, `…/board`, `…/epics`, `…/stats`, `…/analytics?days=`, `…/insights`, `…/activity`, `…/members` (POST, PATCH, DELETE) |
| Tickets | `GET /api/me/home` (portada), `GET /api/me/portfolio` (cartera), `GET /api/me/issues` (mi trabajo; filtros `project`, `priority`, `bugsOnly`, `overdue`), `GET/POST /api/projects/{project}/issues`, `GET/PATCH /api/issues/{issue}`, `PUT …/status`, `PUT …/assignee`, `PUT …/release`, `PUT/DELETE …/watch`, `PUT/DELETE …/presence`, `PUT …/parent`, `PUT …/rank`, `POST …/checklist`, `PATCH/DELETE …/checklist/{item}`, `POST/DELETE …/links`, `GET/POST …/comments`, `GET …/history`, `POST /api/issues/{issue}/duplicate`, `PUT/DELETE …/vote` |
| Comentarios | `PATCH/DELETE /api/comments/{commentId}`, `POST …/reactions` |
| Sprints | `GET/POST /api/projects/{project}/sprints`, `GET/PATCH/DELETE /api/sprints/{sprint}`, `POST …/start`, `POST …/close`, `GET …/progress`, `…/burndown`, `…/issues`, `PUT /api/issues/{issue}/sprint` |
| Vistas | `GET/POST /api/projects/{project}/filters`, `DELETE /api/filters/{id}` |
| Avisos | `GET /api/me/notifications`, `POST /api/me/notifications/read`, `GET/PUT /api/me/notification-settings` |
| Adjuntos | `POST /api/issues/{issue}/attachments` (multipart: `file` y opcional `commentId`), `GET/DELETE /api/issues/{issue}/attachments/{id}` |
| Releases | `GET /api/me/releases` (próximas), `GET/POST /api/projects/{project}/releases`, `GET/PATCH /api/releases/{id}`, `PUT …/notes`, `GET …/notes/draft`, `…/progress`, `…/burnup`, `…/issues`, `POST …/publish`, `POST …/archive` |
| Explorar | `GET /api/explore/projects` (proyectos públicos, sin token) |
| Compartir | `GET/PUT/DELETE /api/projects/{project}/share` (enlace para clientes), `GET /api/shared/dashboards/{token}` (lo que enseña, sin token) |
| Importar | `GET /api/projects/{project}/export.csv`, `POST /api/projects/{project}/import?dryRun=` (multipart `file`) |
| Webhooks | `GET /api/webhook-events`, `GET/POST /api/projects/{project}/webhooks`, `PATCH/DELETE /api/outgoing-webhooks/{id}`, `POST …/test` |
| Git | `GET /api/projects/{project}/git-integration`, `POST …/git-integration/repositories`, `POST …/repositories/{id}/secret`, `DELETE …/repositories/{id}`, `POST /api/webhooks/git/{project}` (lo llama el host de Git) |
| Explorar | `GET /api/explore/projects/{project}/showcase` (sin token, proyectos públicos): hoja de ruta con votos, versiones, sprint en curso, tickets `good-first-issue` y propuestas abiertas; `POST …/proposals` (sin token) |
| Sistema | `GET /api/health`, `GET /api/auth/config`, `POST /api/auth/token`, `POST /api/webhooks/pireon` |
| Recurso | Rutas |
| ----------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Usuarios | `GET /api/users/me`, `GET /api/users?search=`, `GET /api/users/{id}` |
| Proyectos | `GET/POST /api/projects`, `GET/PATCH/DELETE /api/projects/{project}` (`DELETE …?confirm=CLAVE`), `POST …/archive`, `POST …/unarchive`, `PUT …/settings`, `…/board`, `…/epics`, `…/stats`, `…/analytics?days=`, `…/insights`, `…/activity`, `…/members` (POST, PATCH, DELETE) |
| Tickets | `GET /api/me/home` (portada), `GET /api/me/portfolio` (cartera), `GET /api/me/issues` (mi trabajo; filtros `project`, `priority`, `bugsOnly`, `overdue`), `GET/POST /api/projects/{project}/issues`, `GET/PATCH /api/issues/{issue}`, `PUT …/status`, `PUT …/assignee`, `PUT …/release`, `PUT/DELETE …/watch`, `GET /api/me/pins` y `PUT/DELETE …/pin` (fijados), `PUT/DELETE …/presence`, `PUT …/parent`, `PUT …/rank`, `POST …/checklist`, `PATCH/DELETE …/checklist/{item}`, `POST/DELETE …/links`, `GET/POST …/comments`, `GET …/history`, `POST /api/issues/{issue}/duplicate`, `PUT/DELETE …/vote` |
| Comentarios | `PATCH/DELETE /api/comments/{commentId}`, `POST …/reactions`, `PUT/DELETE …/decision`; `GET /api/issues/{issue}/decisions`, `GET /api/projects/{project}/decisions`, `GET /api/me/decisions` |
| Sprints | `GET/POST /api/projects/{project}/sprints`, `GET/PATCH/DELETE /api/sprints/{sprint}`, `POST …/start`, `POST …/close`, `GET …/progress`, `…/burndown`, `…/issues`, `PUT /api/issues/{issue}/sprint` |
| Vistas | `GET/POST /api/projects/{project}/filters`, `DELETE /api/filters/{id}` |
| Avisos | `GET /api/me/notifications`, `POST /api/me/notifications/read`, `GET/PUT /api/me/notification-settings` |
| Adjuntos | `POST /api/issues/{issue}/attachments` (multipart: `file` y opcional `commentId`), `GET/DELETE /api/issues/{issue}/attachments/{id}` |
| Releases | `GET /api/me/releases` (próximas), `GET/POST /api/projects/{project}/releases`, `GET/PATCH /api/releases/{id}`, `PUT …/notes`, `GET …/notes/draft`, `…/progress`, `…/burnup`, `…/issues`, `POST …/publish`, `POST …/archive` |
| Explorar | `GET /api/explore/projects` (proyectos públicos, sin token) |
| Compartir | `GET/PUT/DELETE /api/projects/{project}/share` (enlace para clientes), `GET /api/shared/dashboards/{token}` (lo que enseña, sin token) |
| Importar | `GET /api/projects/{project}/export.csv`, `POST /api/projects/{project}/import?dryRun=` (multipart `file`) |
| Webhooks | `GET /api/webhook-events`, `GET/POST /api/projects/{project}/webhooks`, `PATCH/DELETE /api/outgoing-webhooks/{id}`, `POST …/test` |
| Git | `GET /api/projects/{project}/git-integration`, `POST …/git-integration/repositories`, `POST …/repositories/{id}/secret`, `DELETE …/repositories/{id}`, `POST /api/webhooks/git/{project}` (lo llama el host de Git) |
| Alertas | `GET/PUT/DELETE /api/projects/{project}/alert-integration`, `POST …/alert-integration/token`, `POST /api/webhooks/alerts/{project}/{token}` |
| Explorar | `GET /api/explore/projects/{project}/showcase` (sin token, proyectos públicos): hoja de ruta con votos, versiones, sprint en curso, tickets `good-first-issue` y propuestas abiertas; `POST …/proposals` (sin token) |
| Sistema | `GET /api/health`, `GET /api/auth/config`, `POST /api/auth/token`, `POST /api/webhooks/pireon` |
Los archivos van por REST porque GraphQL no está hecho para bytes; la lista de adjuntos de un ticket llega con el
ticket (`attachments`, en GraphQL y en REST). La descarga sirve en línea solo imágenes, vídeo, audio, PDF y texto

View File

@@ -8,7 +8,7 @@ API repartida en `libs/web/*`.
| Librería | Contenido |
| ----------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `web/shared/infrastructure` | Cliente Apollo (`provideGraphql`: HTTP + `graphql-ws`), `API_CONFIG`, puerto `AccessTokenSource` |
| `web/shared/ui` | Integración de `@pi/ui` (`providePiUi`), modo oscuro, iconos, `ToastService`, `DrawerService`, `AppShellComponent` |
| `web/shared/ui` | Integración de `@pi/ui` (`providePiUi`), modo oscuro, iconos, `ToastService` (con `undoable`: aviso con «Deshacer», `UndoToastComponent`), `DrawerService`, `AppShellComponent` |
| `web/identity/domain` | `Session`, `CurrentUser`, `AuthConfig` y puertos `AuthGateway`, `ProfileGateway`, `SessionStorage`, `AuthorizationFlow` |
| `web/identity/application` | `AuthFacade` (pública); por dentro `AuthStore` y eventos `authPageEvents`, `authApiEvents`, `sessionEvents` |
| `web/identity/infrastructure` | `/api/auth/*` (HTTP), `me` (GraphQL), `localStorage`, PKCE con Web Crypto; enlaza `AccessTokenSource` con la `AuthFacade` |
@@ -118,7 +118,7 @@ Qué gana la página:
| `MyViewsFacade` | `MyViewsStore` | ruta `/` (portada) | Mis vistas guardadas de «Mi trabajo» (entre proyectos: proyectos, asignados a mí, que sigo, estados, prioridades, solo bugs, vencidos, texto) y los tickets de la elegida |
| `ExploreFacade` | `ExploreStore` | ruta `explore` | Proyectos públicos («Explorar»), también sin sesión |
| `ProjectFacade` | `ProjectStore` | ruta `/p/:key` | Proyecto, miembros, stats, releases, actividad en vivo, búsqueda de usuarios, configuración (`workflow`, `saveSettings`) y estadísticas (`loadAnalytics`, `loadBurnup`) |
| `BoardFacade` | `BoardStore` | ruta `board` | Tablero kanban, filtros, movimiento optimista, nuevo ticket, cambios en vivo |
| `BoardFacade` | `BoardStore` | ruta `board` | Tablero kanban, filtros, movimiento optimista (con «Deshacer»), prioridad, estimación y persona cambiadas desde la tarjeta, nuevo ticket, cambios en vivo |
| `ListFacade` | `ListStore` | ruta `list` | Todos los tickets del proyecto en filas: filtros (enviados a la API), cambios de estado, prioridad y persona en la fila, edición en bloque; se recarga tras cada cambio propio y con los de otros |
| `IssueFacade` | `IssueStore` | ruta `issues/:issueKey` | Ticket, comentarios en vivo, historial y todas sus acciones |
@@ -301,17 +301,17 @@ visualización de la plataforma:
| -------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `/login` | Inicio de sesión (solo sin sesión) |
| `/auth/callback` | Retorno de Pireon (PKCE) |
| `/` | Inicio: saludo según la hora con el resumen del día, «Ir a…» (`WEB-12`), primeros pasos, mis proyectos con su actividad (lo primero, para llegar antes), cuatro cifras (`ts-home-kpis`: abiertos míos, esperando QA, terminados por mí y por el equipo esta semana), la semana (`ts-week-agenda`), mis vistas guardadas y dos columnas: mis tickets y lo último en mis proyectos; sprints en curso, esperando QA, que sigo, próximas releases, quién está ahora y «Retomar» |
| `/` | Inicio: saludo según la hora con el resumen del día, «Ir a…» (`WEB-12`), primeros pasos, mis proyectos con su actividad (lo primero, para llegar antes), mis fijados (`ts-pinned`, una franja que se quita con ×; solo si hay), cuatro cifras (`ts-home-kpis`: abiertos míos, esperando QA, terminados por mí y por el equipo esta semana), la semana (`ts-week-agenda`), mis vistas guardadas y dos columnas: mis tickets y lo último en mis proyectos; sprints en curso, esperando QA, que sigo, próximas releases, quién está ahora y «Retomar» |
| `/portfolio` | Cartera (con sesión): una tarjeta por proyecto activo con lo abierto y en riesgo, el sprint en curso (¿llega?), las próximas releases con su previsión y las épicas abiertas; en directo: se vuelve a leer sola cuando cambia un ticket, sprint o release de esos proyectos |
| `/explore` | Proyectos públicos (sin sesión) |
| `/p/:key/board` | Tablero kanban (`?release=` y `?assignee=` preseleccionan los filtros) |
| `/p/:key/issues/:issueKey` | Ticket. Encima del título, quién más lo está viendo o editando ahora (`PresenceFacade`: late cada 10 s; editar es tener abierto el formulario del ticket o de un comentario, o estar escribiendo uno) |
| `/p/:key/board` | Tablero kanban (`?release=` y `?assignee=` preseleccionan los filtros). Quien puede contribuir cambia prioridad, estimación y persona desde la propia tarjeta (selectores nativos sobre sus distintivos; el título es el enlace). Mover una tarjeta o cambiar su persona ofrece «Deshacer» unos segundos (o Ctrl/⌘ Z): la orden contraria, que el flujo puede rechazar |
| `/p/:key/issues/:issueKey` | Ticket. Encima del título, quién más lo está viendo o editando ahora (`PresenceFacade`: late cada 10 s; editar es tener abierto el formulario del ticket o de un comentario, o estar escribiendo uno). «Fijar» lo deja a mano en el inicio y en la paleta de búsqueda. Arriba, sus decisiones (comentarios marcados con la bandera por quien puede cambiar el ticket); `?comment=<id>` lleva a un comentario y lo señala. Cambiar el estado o la persona y mandarlo a la papelera (sin confirmación) ofrecen «Deshacer» |
| `/p/:key/releases` | Releases |
| `/p/:key/report/:kind/:id` | Informe para imprimir de un sprint (`sprint`) o una release (`release`): datos, totales, notas y tickets por etapa. «Imprimir o guardar en PDF» abre el diálogo del navegador; al imprimir se quitan la cabecera, las pestañas y los fondos (`styles.scss`) |
| `/p/:key/roadmap` | Hoja de ruta: épicas en el tiempo, con sprints y releases; flechas «bloquea» / «depende de» entre épicas y aviso si una empieza antes de que acabe la que la bloquea |
| `/p/:key/dashboard` | Seguimiento: cifras, flujo por estado, lo que ha cambiado desde tu última visita (guardada en este navegador), sprint en curso, tickets en riesgo, previsión de las próximas releases, antigüedad de lo que está en curso, cuellos de botella, salud del backlog (cada número abre la lista filtrada: `?missing=estimate`, `area`, `assignee` o `?staleDays=90`), calidad (bugs por prioridad y por semana, QA por área, bugs tras cada release), abiertos por un campo de lista a elegir, el recorrido de un ticket (dónde se va el tiempo de creado a hecho y qué parte es trabajo), la actividad de 12 semanas en un mapa de calor (pulsar un día abre lo que pasó, ticket a ticket), el embudo de propuestas del público (si las hay), equipo con sus puntos abiertos y tipos. Se recarga solo con cada cambio. «Modo pantalla» lo pone a pantalla completa para una tele del equipo: un bloque cada vez, ampliado, que cambia cada 15 s (← → para moverse, espacio para pausar, Esc para salir), con la hora y en directo |
| `/p/:key/analytics` | Estadísticas: creados vs. resueltos, flujo acumulado, burnup de una release, tiempos de ciclo y entrega, QA y resueltos por persona (30 días a 1 año) |
| `/p/:key/settings` | Configuración (propietario y admins; pestaña oculta para el resto), con un menú de secciones (`?section=`): General (visibilidad, propuestas, panel para clientes), Flujo de trabajo (estados, transiciones, definición de «Hecho»), Tickets (tipos, áreas, campos), Equipos, Automatizaciones, Integraciones (Git, webhooks), Importar y exportar y Zona peligrosa; archivar en lugar de borrar, y un punto en el menú donde hay cambios sin guardar |
| `/p/:key/settings` | Configuración (propietario y admins; pestaña oculta para el resto), con un menú de secciones (`?section=`): General (visibilidad, propuestas, panel para clientes), Flujo de trabajo (estados, transiciones, definición de «Hecho»), Tickets (tipos, áreas, campos), Equipos, Automatizaciones, Integraciones (Git, alertas → bugs con su URL secreta y cómo conectar Sentry, Uptime Kuma o Grafana, webhooks), Importar y exportar y Zona peligrosa; archivar en lugar de borrar, y un punto en el menú donde hay cambios sin guardar |
| `/p/:key/activity` | Actividad en vivo |
| `/p/:key/propose` | Formulario público para reportar un error o proponer una idea sin cuenta (si el proyecto lo tiene abierto); llegan como tickets con la etiqueta «propuesta» |
| `/share/:token` | Panel para clientes, sin sesión: los bloques que eligió el proyecto (flujo, sprint, releases, épicas, actividad), sin enlaces hacia dentro de la aplicación; se vuelve a leer cada minuto. Si el enlace se renovó o retiró, «Este enlace ya no funciona» |

View File

@@ -54,7 +54,7 @@ flowchart LR
| | `get_project_activity` | Actividad reciente, o la de un día (`day`) |
| Tickets | `my_issues` | Mi trabajo en todos los proyectos (o de cualquiera, por categoría; o los que sigo); también por prioridad, solo bugs o vencidos |
| | `search_issues` | Buscar por texto y filtros (épica, sprint, fechas, campos…); el backlog: `noSprint` y orden `RANK` |
| | `get_issue` | Detalle: épica, lista de comprobación, relaciones, commits, seguidores, transiciones, comentarios |
| | `get_issue` | Detalle: épica, lista de comprobación, relaciones, commits, seguidores, transiciones, decisiones y comentarios |
| | `create_issue` | Crear un ticket (también colgado de una épica) |
| | `update_issue` | Cambiar título, descripción, tipo, área, prioridad, etiquetas, fechas, campos personalizados… |
| | `change_issue_status` | Mover de estado (con motivo cuando el estado lo pide) |
@@ -66,6 +66,8 @@ flowchart LR
| | `vote_issue` | Votar un ticket o quitar el voto |
| | `duplicate_issue` | Crear otro ticket con lo mismo (sin estado, persona ni comentarios) |
| | `react_to_comment` | Reaccionar a un comentario (👍 🎉…) o quitar la reacción |
| | `mark_decision` | Marcar un comentario como decisión del ticket, o quitarla |
| | `search_decisions` | Qué se decidió: decisiones por su texto, de un proyecto o de todos los míos |
| | `add_checklist_item` / `update_checklist_item` / `remove_checklist_item` | La lista de comprobación: añadir, marcar o reescribir, quitar |
| Organización | `list_epics` | Épicas con el progreso de lo que cuelga de cada una |
| | `set_issue_parent` | Colgar un ticket de una épica u otro ticket, o soltarlo |

View File

@@ -33,6 +33,13 @@ export * from './lib/git/git-payloads';
export * from './lib/saved-filters/saved-filters';
export * from './lib/saved-filters/my-views';
export * from './lib/ports/my-views.repository';
export * from './lib/saved-filters/pins';
export * from './lib/ports/pins.repository';
export * from './lib/pins.service';
export * from './lib/alerts/alert-payloads';
export * from './lib/alerts/alerts';
export * from './lib/ports/alert-integrations.repository';
export * from './lib/alerts.service';
export * from './lib/tracker-application.module';
export * from './lib/ports/presence.store';
export * from './lib/presence.service';

View File

@@ -0,0 +1,86 @@
import { timingSafeEqual } from 'node:crypto';
import { CommandRunner } from '@task-stream/api/shared/application';
import { Injectable } from '@nestjs/common';
import { QueryBus } from '@nestjs/cqrs';
import { ForbiddenError } from '@task-stream/api/shared/domain';
import { alertOf } from './alerts/alert-payloads';
import {
AlertIntegration,
AlertOutcome,
AlertSettings,
DisableAlertIntegrationCommand,
GetAlertIntegrationQuery,
ReceiveAlertCommand,
RotateAlertTokenCommand,
SaveAlertIntegrationCommand,
} from './alerts/alerts';
import { AlertIntegrationsRepository } from './ports/alert-integrations.repository';
import { FindProjectsRepository } from './ports/find-projects.repository';
/** What a monitoring tool sent to a project's alert URL. */
export interface AlertDelivery {
/** Project id or key, from the URL. */
project: string;
token: string;
body: unknown;
headers: Record<string, string | undefined>;
}
/** Alerts → bugs: the project's secret URL, and what arrives through it. */
@Injectable()
export class AlertsService {
constructor(
private readonly commands: CommandRunner,
private readonly queryBus: QueryBus,
private readonly findProjects: FindProjectsRepository,
private readonly integrations: AlertIntegrationsRepository,
) {}
get(viewerId: string, projectId: string): Promise<AlertIntegration> {
return this.queryBus.execute(
new GetAlertIntegrationQuery(viewerId, projectId),
);
}
save(
actorId: string,
projectId: string,
settings: AlertSettings,
): Promise<AlertIntegration> {
return this.commands.execute(
new SaveAlertIntegrationCommand(actorId, projectId, settings),
);
}
rotateToken(actorId: string, projectId: string): Promise<AlertIntegration> {
return this.commands.execute(
new RotateAlertTokenCommand(actorId, projectId),
);
}
disable(actorId: string, projectId: string): Promise<void> {
return this.commands.execute(
new DisableAlertIntegrationCommand(actorId, projectId),
);
}
/** Lets an alert in only with the project's current token. */
async receive(delivery: AlertDelivery): Promise<AlertOutcome> {
const project =
(await this.findProjects.findById(delivery.project)) ??
(await this.findProjects.findByKey(delivery.project.toUpperCase()));
const config = project ? await this.integrations.find(project.id) : null;
// One answer for an unknown project, the integration off or a bad token.
if (!project || !config || !sameToken(config.token, delivery.token))
throw new ForbiddenError('Unknown project, alerts off or bad token');
const alert = alertOf(delivery.body, delivery.headers);
if (!alert) return { action: 'ignored', issueKey: null };
return this.commands.execute(new ReceiveAlertCommand(project.id, alert));
}
}
function sameToken(expected: string, given: string): boolean {
const a = Buffer.from(expected);
const b = Buffer.from(given ?? '');
return a.length === b.length && timingSafeEqual(a, b);
}

View File

@@ -0,0 +1,112 @@
import { alertOf } from './alert-payloads';
describe('alertOf', () => {
it('reads a Sentry issue (internal integration), resolved too', () => {
const body = {
action: 'created',
data: {
issue: {
id: '42',
title: 'TypeError: x is undefined',
culprit: 'app/checkout.ts',
permalink: 'https://sentry.io/organizations/acme/issues/42/',
level: 'error',
},
},
};
const alert = alertOf(body, { 'sentry-hook-resource': 'issue' });
expect(alert).toMatchObject({
source: 'sentry',
fingerprint: 'sentry:42',
title: 'TypeError: x is undefined',
url: 'https://sentry.io/organizations/acme/issues/42/',
resolved: false,
});
expect(alert?.details).toContain('app/checkout.ts');
expect(
alertOf(
{ ...body, action: 'resolved' },
{ 'sentry-hook-resource': 'issue' },
)?.resolved,
).toBe(true);
});
it('reads a Sentry issue alert and the legacy webhook', () => {
expect(
alertOf(
{
action: 'triggered',
data: {
event: { issue_id: '7', title: 'Boom', web_url: 'https://s.io/7' },
triggered_rule: 'Errores nuevos',
},
},
{ 'sentry-hook-resource': 'event_alert' },
),
).toMatchObject({ fingerprint: 'sentry:7', title: 'Boom' });
expect(
alertOf({
id: '99',
project_name: 'web',
message: 'Boom',
url: 'https://s.io/99',
event: { title: 'ReferenceError: y' },
}),
).toMatchObject({ fingerprint: 'sentry:99', title: 'ReferenceError: y' });
});
it('reads Uptime Kuma: down opens, up resolves', () => {
const down = {
heartbeat: { status: 0, msg: 'timeout', time: '2026-10-17 10:00' },
monitor: { id: 3, name: 'API', url: 'https://api.acme.dev' },
msg: '[API] [🔴 Down] timeout',
};
expect(alertOf(down)).toMatchObject({
source: 'uptime-kuma',
fingerprint: 'uptime-kuma:3',
title: 'API no responde',
resolved: false,
});
expect(alertOf({ ...down, heartbeat: { status: 1 } })?.resolved).toBe(true);
});
it('reads Alertmanager and Grafana', () => {
expect(
alertOf({
status: 'firing',
groupKey: '{}:{alertname="HighLatency"}',
alerts: [
{
labels: { alertname: 'HighLatency', service: 'api' },
annotations: { summary: 'p95 por encima de 2 s' },
generatorURL: 'https://grafana.acme.dev/alerting/1',
},
],
}),
).toMatchObject({
source: 'alertmanager',
fingerprint: 'alertmanager:{}:{alertname="HighLatency"}',
title: 'p95 por encima de 2 s',
resolved: false,
});
});
it('takes a plain title, and nothing without one', () => {
expect(
alertOf({ title: 'Cola atascada', fingerprint: 'queue', resolved: true }),
).toMatchObject({
source: 'generic',
fingerprint: 'generic:queue',
resolved: true,
});
expect(alertOf({ title: 'Cola atascada' })?.fingerprint).toBe(
'generic:cola atascada',
);
expect(alertOf({ ping: true })).toBeNull();
expect(alertOf('nonsense')).toBeNull();
});
it('only links http(s) addresses', () => {
expect(alertOf({ title: 'x', url: 'javascript:alert(1)' })?.url).toBeNull();
});
});

View File

@@ -0,0 +1,199 @@
/*
* What monitoring tools send when something breaks, read into one shape.
* Each tool has its own payload; none of them is trusted beyond being text.
*/
export type AlertSource = 'sentry' | 'uptime-kuma' | 'alertmanager' | 'generic';
export interface Alert {
source: AlertSource;
/**
* The same problem gives the same fingerprint every time: repeats go to
* the ticket it opened instead of opening another.
*/
fingerprint: string;
title: string;
/** Markdown for the ticket's description. */
details: string;
url: string | null;
/** The tool says it is over (back up, resolved). */
resolved: boolean;
}
const MAX_TITLE = 200;
const MAX_DETAILS = 4000;
type Json = Record<string, unknown>;
const obj = (value: unknown): Json =>
value && typeof value === 'object' && !Array.isArray(value)
? (value as Json)
: {};
const str = (value: unknown): string =>
typeof value === 'string'
? value.trim()
: typeof value === 'number'
? String(value)
: '';
/** An http(s) link, or null: anything else is not offered as a link. */
const link = (value: unknown): string | null => {
const text = str(value);
return /^https?:\/\/\S+$/i.test(text) ? text.slice(0, 500) : null;
};
/** The alert in a payload, or null when it is not one (a test ping…). */
export function alertOf(
body: unknown,
headers: Record<string, string | undefined> = {},
): Alert | null {
const payload = obj(body);
const alert =
sentry(payload, headers) ??
uptimeKuma(payload) ??
alertmanager(payload) ??
generic(payload);
if (!alert?.title || !alert.fingerprint) return null;
return {
...alert,
title: alert.title.replace(/\s+/g, ' ').slice(0, MAX_TITLE),
details: alert.details.slice(0, MAX_DETAILS),
fingerprint: alert.fingerprint.slice(0, 300),
};
}
/**
* Sentry: an internal integration (header `sentry-hook-resource`: `issue`
* or `event_alert`) or the legacy webhooks plugin (`project_name`, `event`).
*/
function sentry(
payload: Json,
headers: Record<string, string | undefined>,
): Alert | null {
const resource = str(headers['sentry-hook-resource']);
const data = obj(payload['data']);
if (resource === 'issue') {
const issue = obj(data['issue']);
const id = str(issue['id']);
if (!id) return null;
const action = str(payload['action']);
return {
source: 'sentry',
fingerprint: `sentry:${id}`,
title: str(issue['title']),
details: lines([
str(issue['culprit']) && `En \`${str(issue['culprit'])}\``,
str(issue['level']) && `Nivel: ${str(issue['level'])}`,
]),
url: link(issue['permalink'] ?? issue['web_url']),
resolved: action === 'resolved',
};
}
if (resource === 'event_alert') {
const event = obj(data['event']);
const id = str(event['issue_id']) || str(event['event_id']);
if (!id) return null;
return {
source: 'sentry',
fingerprint: `sentry:${id}`,
title: str(event['title']) || str(event['message']),
details: lines([
str(event['culprit']) && `En \`${str(event['culprit'])}\``,
str(event['level']) && `Nivel: ${str(event['level'])}`,
str(data['triggered_rule']) && `Regla: ${str(data['triggered_rule'])}`,
]),
url: link(event['web_url'] ?? event['url']),
resolved: false,
};
}
if ('project_name' in payload && 'event' in payload) {
const event = obj(payload['event']);
const id = str(payload['id']) || str(event['event_id']);
if (!id) return null;
return {
source: 'sentry',
fingerprint: `sentry:${id}`,
title: str(event['title']) || str(payload['message']),
details: lines([
str(payload['culprit']) && `En \`${str(payload['culprit'])}\``,
str(payload['level']) && `Nivel: ${str(payload['level'])}`,
str(payload['project_name']) &&
`Proyecto de Sentry: ${str(payload['project_name'])}`,
]),
url: link(payload['url']),
resolved: false,
};
}
return null;
}
/** Uptime Kuma's webhook: `monitor` and `heartbeat` (status 0 down, 1 up). */
function uptimeKuma(payload: Json): Alert | null {
const monitor = obj(payload['monitor']);
const heartbeat = obj(payload['heartbeat']);
if (!('monitor' in payload) || !('heartbeat' in payload)) return null;
const name = str(monitor['name']);
const id = str(monitor['id']) || name;
if (!id) return null;
const up = heartbeat['status'] === 1;
return {
source: 'uptime-kuma',
fingerprint: `uptime-kuma:${id}`,
title: `${name || 'Un monitor'} no responde`,
details: lines([
str(heartbeat['msg']) && `Respuesta: ${str(heartbeat['msg'])}`,
str(monitor['url']) && `Vigila: ${str(monitor['url'])}`,
str(heartbeat['time']) && `Desde: ${str(heartbeat['time'])}`,
]),
url: link(monitor['url']),
resolved: up,
};
}
/** Prometheus Alertmanager and Grafana: `status` and `alerts[]`. */
function alertmanager(payload: Json): Alert | null {
if (!Array.isArray(payload['alerts'])) return null;
const first = obj(payload['alerts'][0]);
const labels = obj(first['labels']);
const annotations = obj(first['annotations']);
const key = str(payload['groupKey']) || str(first['fingerprint']);
if (!key) return null;
return {
source: 'alertmanager',
fingerprint: `alertmanager:${key}`,
title:
str(annotations['summary']) ||
str(labels['alertname']) ||
str(payload['title']),
details: lines([
str(annotations['description']) || str(payload['message']),
Object.keys(labels).length > 0 &&
`Etiquetas: ${Object.entries(labels)
.map(([k, v]) => `\`${k}=${str(v)}\``)
.join(' ')}`,
]),
url: link(first['generatorURL'] ?? payload['externalURL']),
resolved: str(payload['status']) === 'resolved',
};
}
/**
* Anything else that says what broke: `title` (or `message`), and
* optionally `fingerprint`, `description`, `url` and `resolved`.
*/
function generic(payload: Json): Alert | null {
const title = str(payload['title']) || str(payload['message']);
if (!title) return null;
return {
source: 'generic',
fingerprint: `generic:${str(payload['fingerprint']) || title.toLowerCase()}`,
title,
details: str(payload['description']),
url: link(payload['url']),
resolved:
payload['resolved'] === true || str(payload['status']) === 'resolved',
};
}
function lines(parts: (string | false | undefined)[]): string {
return parts.filter((part): part is string => !!part).join('\n\n');
}

View File

@@ -0,0 +1,220 @@
import {
CommandBus,
CommandHandler,
CqrsModule,
ICommandHandler,
} from '@nestjs/cqrs';
import { Test } from '@nestjs/testing';
import { CommandRunner } from '@task-stream/api/shared/application';
import {
IssuePriority,
IssueReadModel,
StatusCategory,
SYSTEM_ACTOR_ID,
} from '@task-stream/api/tracker/domain';
import { AddCommentCommand } from '../commands/add-comment.command';
import { CreateIssueCommand } from '../commands/create-issue.command';
import { WriteAccess } from '../commands/write-access';
import {
AlertIntegrationConfig,
AlertIntegrationsRepository,
AlertTrace,
} from '../ports/alert-integrations.repository';
import { FindIssuesRepository } from '../ports/find-issues.repository';
import { FindProjectsRepository } from '../ports/find-projects.repository';
import { ReadAccess } from '../queries/read-access';
import { Alert } from './alert-payloads';
import { ALERT_HANDLERS, ReceiveAlertCommand, REPEAT_QUIET_MS } from './alerts';
class InMemoryAlerts extends AlertIntegrationsRepository {
configs = new Map<string, AlertIntegrationConfig>();
traces = new Map<string, AlertTrace>();
async find(projectId: string) {
return this.configs.get(projectId) ?? null;
}
async save(config: AlertIntegrationConfig) {
this.configs.set(config.projectId, config);
}
async remove(projectId: string) {
this.configs.delete(projectId);
}
async trace(projectId: string, fingerprint: string) {
return this.traces.get(`${projectId}:${fingerprint}`) ?? null;
}
async saveTrace(trace: AlertTrace) {
this.traces.set(`${trace.projectId}:${trace.fingerprint}`, trace);
}
}
/** The tickets alerts open, and what TaskStream says on them. */
const issues = new Map<string, IssueReadModel>();
const created: CreateIssueCommand[] = [];
const comments: AddCommentCommand[] = [];
@CommandHandler(CreateIssueCommand)
class FakeCreate implements ICommandHandler<CreateIssueCommand> {
async execute(command: CreateIssueCommand) {
created.push(command);
const id = `i${created.length}`;
const key = `WEB-${created.length}`;
issues.set(id, {
id,
key,
statusCategory: StatusCategory.TODO,
trashedAt: null,
} as IssueReadModel);
return { id, key, version: 1 };
}
}
@CommandHandler(AddCommentCommand)
class FakeComment implements ICommandHandler<AddCommentCommand> {
async execute(command: AddCommentCommand) {
comments.push(command);
return { id: 'c', version: 1 };
}
}
const alert = (over: Partial<Alert> = {}): Alert => ({
source: 'uptime-kuma',
fingerprint: 'uptime-kuma:3',
title: 'API no responde',
details: 'timeout',
url: 'https://api.acme.dev',
resolved: false,
...over,
});
async function setup() {
issues.clear();
created.length = 0;
comments.length = 0;
const repo = new InMemoryAlerts();
await repo.save({
projectId: 'p1',
token: 't',
typeId: null,
priority: IssuePriority.HIGH,
createdAt: '2026-10-01T00:00:00Z',
});
const moduleRef = await Test.createTestingModule({
imports: [CqrsModule.forRoot()],
providers: [
...ALERT_HANDLERS,
FakeCreate,
FakeComment,
CommandRunner,
{ provide: AlertIntegrationsRepository, useValue: repo },
{
provide: FindProjectsRepository,
useValue: {
findById: async () => ({
id: 'p1',
settings: {
issueTypes: [
{ id: 'task', icon: 'task', archived: false },
{ id: 'bug', icon: 'bug', archived: false },
],
},
}),
},
},
{
provide: FindIssuesRepository,
useValue: { findById: async (id: string) => issues.get(id) ?? null },
},
{ provide: WriteAccess, useValue: {} },
{ provide: ReadAccess, useValue: {} },
],
}).compile();
await moduleRef.init();
const bus = moduleRef.get(CommandBus);
return {
repo,
receive: (a: Alert) => bus.execute(new ReceiveAlertCommand('p1', a)),
};
}
describe('alerts → bugs', () => {
it('opens a bug as TaskStream, with the bug type and the priority set', async () => {
const { receive } = await setup();
expect(await receive(alert())).toEqual({
action: 'created',
issueKey: 'WEB-1',
});
const [command] = created;
expect(command.actorId).toBe(SYSTEM_ACTOR_ID);
expect(command.type).toBe('bug');
expect(command.details.priority).toBe(IssuePriority.HIGH);
expect(command.details.labels).toEqual(['alerta']);
expect(command.details.description).toContain('Uptime Kuma');
});
it('sends repeats to the open bug, telling it at most every half hour', async () => {
const { receive, repo } = await setup();
await receive(alert());
expect(await receive(alert())).toEqual({
action: 'repeated',
issueKey: 'WEB-1',
});
// Just told when it opened: this repeat stays quiet.
expect(comments).toHaveLength(0);
const trace = await repo.trace('p1', 'uptime-kuma:3');
if (!trace) throw new Error('no trace');
expect(trace.count).toBe(2);
await repo.saveTrace({
...trace,
notedAt: new Date(Date.now() - REPEAT_QUIET_MS - 1000).toISOString(),
});
await receive(alert());
expect(comments).toHaveLength(1);
expect(comments[0].body).toContain('3 veces');
expect(created).toHaveLength(1);
});
it('says on the bug when it is resolved, and ignores one with nothing open', async () => {
const { receive } = await setup();
expect(await receive(alert({ resolved: true }))).toEqual({
action: 'ignored',
issueKey: null,
});
await receive(alert());
expect(await receive(alert({ resolved: true }))).toEqual({
action: 'resolved',
issueKey: 'WEB-1',
});
expect(comments[0].body).toContain('se ha resuelto');
});
it('opens a new bug when the old one is closed, naming it', async () => {
const { receive } = await setup();
await receive(alert());
issues.set('i1', {
...(issues.get('i1') as IssueReadModel),
statusCategory: StatusCategory.DONE,
});
expect(await receive(alert())).toEqual({
action: 'created',
issueKey: 'WEB-2',
});
expect(created[1].details.description).toContain('Ya pasó antes: WEB-1');
});
it('sends a repeat to a bug not yet in the read model', async () => {
const { receive } = await setup();
await receive(alert());
issues.clear();
expect(await receive(alert())).toEqual({
action: 'repeated',
issueKey: 'WEB-1',
});
expect(created).toHaveLength(1);
});
it('opens one bug when the same alert arrives twice at once', async () => {
const { receive } = await setup();
const both = await Promise.all([receive(alert()), receive(alert())]);
expect(both.map((o) => o.action).sort()).toEqual(['created', 'repeated']);
expect(created).toHaveLength(1);
});
});

View File

@@ -0,0 +1,357 @@
import { randomBytes } from 'node:crypto';
import { CommandRunner } from '@task-stream/api/shared/application';
import {
Command,
CommandHandler,
ICommandHandler,
IQueryHandler,
Query,
QueryHandler,
} from '@nestjs/cqrs';
import { assertThat, NotFoundError } from '@task-stream/api/shared/domain';
import {
IssuePriority,
ProjectRole,
StatusCategory,
SYSTEM_ACTOR_ID,
} from '@task-stream/api/tracker/domain';
import { AddCommentCommand } from '../commands/add-comment.command';
import { CommandResult } from '../commands/command-result';
import { CreateIssueCommand } from '../commands/create-issue.command';
import { WriteAccess } from '../commands/write-access';
import {
AlertIntegrationConfig,
AlertIntegrationsRepository,
} from '../ports/alert-integrations.repository';
import { FindIssuesRepository } from '../ports/find-issues.repository';
import { FindProjectsRepository } from '../ports/find-projects.repository';
import { ReadAccess } from '../queries/read-access';
import { Alert, AlertSource } from './alert-payloads';
/*
* Alerts → bugs: monitoring tools (Sentry, Uptime Kuma, Grafana…) call a
* project's secret URL; a new problem opens a bug, a repeat goes to the bug
* it opened while that one is open, and «resolved» is said on it. Nobody's
* ticket: TaskStream opens and comments them.
*/
export interface AlertIntegration {
enabled: boolean;
/** Only for whoever manages the project. */
token: string | null;
typeId: string | null;
priority: IssuePriority;
}
export interface AlertSettings {
typeId?: string | null;
priority?: IssuePriority;
}
export type AlertOutcome =
| { action: 'created' | 'repeated' | 'resolved'; issueKey: string }
| { action: 'ignored'; issueKey: null };
/** Repeats tell the ticket at most this often. */
export const REPEAT_QUIET_MS = 30 * 60_000;
const SOURCE_NAME: Record<AlertSource, string> = {
sentry: 'Sentry',
'uptime-kuma': 'Uptime Kuma',
alertmanager: 'Alertmanager',
generic: 'la alerta',
};
const OFF: AlertIntegration = {
enabled: false,
token: null,
typeId: null,
priority: IssuePriority.HIGH,
};
const newToken = () => randomBytes(24).toString('hex');
/** Turns it on, or changes what it opens (the token stays). */
export class SaveAlertIntegrationCommand extends Command<AlertIntegration> {
constructor(
public readonly actorId: string,
public readonly projectId: string,
public readonly settings: AlertSettings,
) {
super();
}
}
/** A new URL; the old one stops working at once. */
export class RotateAlertTokenCommand extends Command<AlertIntegration> {
constructor(
public readonly actorId: string,
public readonly projectId: string,
) {
super();
}
}
export class DisableAlertIntegrationCommand extends Command<void> {
constructor(
public readonly actorId: string,
public readonly projectId: string,
) {
super();
}
}
/** An alert already let in (its token checked by the caller). */
export class ReceiveAlertCommand extends Command<AlertOutcome> {
constructor(
public readonly projectId: string,
public readonly alert: Alert,
) {
super();
}
}
export class GetAlertIntegrationQuery extends Query<AlertIntegration> {
constructor(
public readonly viewerId: string,
public readonly projectId: string,
) {
super();
}
}
/** What owners and admins see: the token included. */
const view = (config: AlertIntegrationConfig): AlertIntegration => ({
enabled: true,
token: config.token,
typeId: config.typeId,
priority: config.priority,
});
@CommandHandler(SaveAlertIntegrationCommand)
export class SaveAlertIntegrationCommandHandler
implements ICommandHandler<SaveAlertIntegrationCommand>
{
constructor(
private readonly access: WriteAccess,
private readonly findProjects: FindProjectsRepository,
private readonly integrations: AlertIntegrationsRepository,
) {}
async execute(
command: SaveAlertIntegrationCommand,
): Promise<AlertIntegration> {
(await this.access.project(command.projectId)).assertCanManage(
command.actorId,
);
const { typeId, priority } = command.settings;
if (typeId) {
const project = await this.findProjects.findById(command.projectId);
assertThat(
!!project?.settings.issueTypes.some(
(t) => t.id === typeId && !t.archived,
),
`Unknown ticket type: ${typeId}`,
);
}
const current = await this.integrations.find(command.projectId);
const config: AlertIntegrationConfig = {
projectId: command.projectId,
token: current?.token ?? newToken(),
typeId: typeId === undefined ? (current?.typeId ?? null) : typeId,
priority: priority ?? current?.priority ?? IssuePriority.HIGH,
createdAt: current?.createdAt ?? new Date().toISOString(),
};
await this.integrations.save(config);
return view(config);
}
}
@CommandHandler(RotateAlertTokenCommand)
export class RotateAlertTokenCommandHandler
implements ICommandHandler<RotateAlertTokenCommand>
{
constructor(
private readonly access: WriteAccess,
private readonly integrations: AlertIntegrationsRepository,
) {}
async execute(command: RotateAlertTokenCommand): Promise<AlertIntegration> {
(await this.access.project(command.projectId)).assertCanManage(
command.actorId,
);
const current = await this.integrations.find(command.projectId);
if (!current)
throw new NotFoundError('Alert integration', command.projectId);
const rotated = { ...current, token: newToken() };
await this.integrations.save(rotated);
return view(rotated);
}
}
@CommandHandler(DisableAlertIntegrationCommand)
export class DisableAlertIntegrationCommandHandler
implements ICommandHandler<DisableAlertIntegrationCommand>
{
constructor(
private readonly access: WriteAccess,
private readonly integrations: AlertIntegrationsRepository,
) {}
async execute(command: DisableAlertIntegrationCommand): Promise<void> {
(await this.access.project(command.projectId)).assertCanManage(
command.actorId,
);
await this.integrations.remove(command.projectId);
}
}
@CommandHandler(ReceiveAlertCommand)
export class ReceiveAlertCommandHandler
implements ICommandHandler<ReceiveAlertCommand>
{
/** One alert of a project after another: two at once open one ticket. */
private readonly queues = new Map<string, Promise<unknown>>();
constructor(
private readonly integrations: AlertIntegrationsRepository,
private readonly findProjects: FindProjectsRepository,
private readonly findIssues: FindIssuesRepository,
private readonly commands: CommandRunner,
) {}
execute(command: ReceiveAlertCommand): Promise<AlertOutcome> {
const previous = this.queues.get(command.projectId) ?? Promise.resolve();
const next = previous.catch(() => null).then(() => this.receive(command));
this.queues.set(command.projectId, next);
void next.finally(() => {
if (this.queues.get(command.projectId) === next)
this.queues.delete(command.projectId);
});
return next;
}
private async receive({
projectId,
alert,
}: ReceiveAlertCommand): Promise<AlertOutcome> {
const config = await this.integrations.find(projectId);
if (!config) throw new NotFoundError('Alert integration', projectId);
const now = new Date().toISOString();
const trace = await this.integrations.trace(projectId, alert.fingerprint);
const opened = trace ? await this.findIssues.findById(trace.issueId) : null;
// Not in the read model yet: it was opened a moment ago, so it is open.
const open = !trace
? null
: !opened
? { id: trace.issueId, key: trace.issueKey }
: !opened.trashedAt &&
opened.statusCategory !== StatusCategory.DONE &&
opened.statusCategory !== StatusCategory.CANCELLED
? opened
: null;
const source = SOURCE_NAME[alert.source];
if (trace && open) {
if (alert.resolved) {
await this.comment(open.id, `✅ Según ${source}, se ha resuelto.`);
await this.integrations.saveTrace({ ...trace, notedAt: now });
return { action: 'resolved', issueKey: open.key };
}
const count = trace.count + 1;
const quiet = Date.now() - Date.parse(trace.notedAt) < REPEAT_QUIET_MS;
if (!quiet)
await this.comment(
open.id,
`🔁 Ha vuelto a pasar: ${count} veces desde ${trace.firstAt.slice(0, 16).replace('T', ' ')} (UTC).`,
);
await this.integrations.saveTrace({
...trace,
count,
lastAt: now,
notedAt: quiet ? trace.notedAt : now,
});
return { action: 'repeated', issueKey: open.key };
}
// Over before anyone looked, or about a ticket already closed.
if (alert.resolved) return { action: 'ignored', issueKey: null };
const project = await this.findProjects.findById(projectId);
const types = (project?.settings.issueTypes ?? []).filter(
(t) => !t.archived,
);
const type =
types.find((t) => t.id === config.typeId) ??
types.find((t) => t.icon === 'bug') ??
types[0];
if (!type) throw new NotFoundError('Ticket type', config.typeId ?? 'bug');
const description = [
alert.details,
alert.url && `[Ver en ${source}](${alert.url})`,
opened && `Ya pasó antes: ${opened.key}.`,
`_Abierto por una alerta de ${source}._`,
]
.filter(Boolean)
.join('\n\n');
const result: CommandResult = await this.commands.execute(
new CreateIssueCommand(
SYSTEM_ACTOR_ID,
projectId,
type.id,
alert.title,
{ description, priority: config.priority, labels: ['alerta'] },
null,
null,
null,
null,
false,
),
);
await this.integrations.saveTrace({
projectId,
fingerprint: alert.fingerprint,
issueId: result.id,
issueKey: result.key ?? result.id,
count: 1,
firstAt: now,
lastAt: now,
notedAt: now,
});
return { action: 'created', issueKey: result.key ?? result.id };
}
private comment(issueId: string, body: string): Promise<CommandResult> {
return this.commands.execute(
new AddCommentCommand(SYSTEM_ACTOR_ID, issueId, body),
);
}
}
@QueryHandler(GetAlertIntegrationQuery)
export class GetAlertIntegrationQueryHandler
implements IQueryHandler<GetAlertIntegrationQuery, AlertIntegration>
{
constructor(
private readonly access: ReadAccess,
private readonly integrations: AlertIntegrationsRepository,
) {}
async execute(query: GetAlertIntegrationQuery): Promise<AlertIntegration> {
const role = await this.access.assertSeesWholeProject(
query.projectId,
query.viewerId,
);
const config = await this.integrations.find(query.projectId);
if (!config) return OFF;
const manages = role === ProjectRole.OWNER || role === ProjectRole.ADMIN;
return { ...view(config), token: manages ? config.token : null };
}
}
export const ALERT_HANDLERS = [
SaveAlertIntegrationCommandHandler,
RotateAlertTokenCommandHandler,
DisableAlertIntegrationCommandHandler,
ReceiveAlertCommandHandler,
GetAlertIntegrationQueryHandler,
];

View File

@@ -1,6 +1,9 @@
import { CommandHandler, EventPublisher, ICommandHandler } from '@nestjs/cqrs';
import { BusinessRuleError } from '@task-stream/api/shared/domain';
import { CommentFactory } from '@task-stream/api/tracker/domain';
import {
CommentFactory,
SYSTEM_ACTOR_ID,
} from '@task-stream/api/tracker/domain';
import { AddCommentCommand } from './add-comment.command';
import { CommandResult, ack } from './command-result';
import { WriteAccess } from './write-access';
@@ -16,10 +19,17 @@ export class AddCommentCommandHandler
) {}
async execute(command: AddCommentCommand): Promise<CommandResult> {
const { issue } = await this.access.issueForReporter(
command.issueId,
command.actorId,
);
// TaskStream itself (alerts that repeat) is no member; only internal
// commands act as it, never a request from outside.
const issue =
command.actorId === SYSTEM_ACTOR_ID
? await this.access.issue(command.issueId)
: (await this.access.issueForReporter(command.issueId, command.actorId))
.issue;
if (issue.trashedAt)
throw new BusinessRuleError(
`${issue.key} is in the trash: restore it to change it`,
);
const comment = this.commentFactory.create({
issueId: issue.id,
projectId: issue.projectId,

View File

@@ -1,4 +1,5 @@
import { ReactToCommentCommandHandler } from './react-to-comment.command-handler';
import { MarkDecisionCommandHandler } from './mark-decision.command-handler';
import { VoteIssueCommandHandler } from './vote-issue.command-handler';
import { CreateProjectCommandHandler } from './create-project.command-handler';
import { UpdateProjectCommandHandler } from './update-project.command-handler';
@@ -46,6 +47,7 @@ import {
export const COMMAND_HANDLERS = [
VoteIssueCommandHandler,
ReactToCommentCommandHandler,
MarkDecisionCommandHandler,
CreateProjectCommandHandler,
UpdateProjectCommandHandler,
UpdateProjectSettingsCommandHandler,

View File

@@ -77,6 +77,7 @@ import { UserDirectory } from '../ports/user-directory';
import { AttachmentStorage } from '../ports/attachment-storage';
import { AddCommentCommand } from './add-comment.command';
import { ReactToCommentCommand } from './react-to-comment.command';
import { MarkDecisionCommand } from './mark-decision.command';
import { UpdateProjectCommand } from './update-project.command';
import { VoteIssueCommand } from './vote-issue.command';
import { AddProjectMemberCommand } from './add-project-member.command';
@@ -916,6 +917,32 @@ describe('tracker commands (event sourced)', () => {
).rejects.toThrow(ForbiddenError);
});
it('lets the team mark a comment as a decision, and only the team', async () => {
const { run, rehydrator } = await setup();
const projectId = await projectWithTeam(run);
const issue = await run(
new CreateIssueCommand('dev', projectId, IssueType.TASK, 'Caché', {}),
);
const comment = await run(
new AddCommentCommand('qa', issue.id, 'Usamos Redis'),
);
await run(new MarkDecisionCommand('dev', comment.id, true));
// Twice is still once.
const again = await run(new MarkDecisionCommand('dev', comment.id, true));
expect(again.version).toBe(2);
expect((await rehydrator.rehydrate(comment.id, Comment)).decision).toBe(
true,
);
// A viewer reads the decisions but does not make them.
await expect(
run(new MarkDecisionCommand('pm', comment.id, false)),
).rejects.toThrow(ForbiddenError);
await run(new MarkDecisionCommand('owner', comment.id, false));
expect((await rehydrator.rehydrate(comment.id, Comment)).decision).toBe(
false,
);
});
it('takes votes from members, and from anyone once it is public', async () => {
const { run, rehydrator } = await setup();
const projectId = await projectWithTeam(run);

View File

@@ -0,0 +1,20 @@
import { CommandHandler, ICommandHandler } from '@nestjs/cqrs';
import { CommandResult, ack } from './command-result';
import { MarkDecisionCommand } from './mark-decision.command';
import { WriteAccess } from './write-access';
/** The team decides: whoever can change the ticket marks its decisions. */
@CommandHandler(MarkDecisionCommand)
export class MarkDecisionCommandHandler
implements ICommandHandler<MarkDecisionCommand>
{
constructor(private readonly access: WriteAccess) {}
async execute(command: MarkDecisionCommand): Promise<CommandResult> {
const comment = await this.access.comment(command.commentId);
await this.access.issueForContributor(comment.issueId, command.actorId);
comment.markDecision(command.actorId, command.on);
await comment.commitAndWait();
return ack(comment);
}
}

View File

@@ -0,0 +1,13 @@
import { Command } from '@nestjs/cqrs';
import { CommandResult } from './command-result';
/** Marks a comment as a decision of its ticket (`on`) or unmarks it. */
export class MarkDecisionCommand extends Command<CommandResult> {
constructor(
public readonly actorId: string,
public readonly commentId: string,
public readonly on: boolean,
) {
super();
}
}

View File

@@ -1,14 +1,22 @@
import { CommandRunner } from '@task-stream/api/shared/application';
import { Injectable } from '@nestjs/common';
import { QueryBus } from '@nestjs/cqrs';
import { CommentReadModel } from '@task-stream/api/tracker/domain';
import {
CommentReadModel,
DecisionReadModel,
} from '@task-stream/api/tracker/domain';
import { AddCommentCommand } from './commands/add-comment.command';
import { CommandResult } from './commands/command-result';
import { DeleteCommentCommand } from './commands/delete-comment.command';
import { EditCommentCommand } from './commands/edit-comment.command';
import { ReactToCommentCommand } from './commands/react-to-comment.command';
import { MarkDecisionCommand } from './commands/mark-decision.command';
import { Page } from './ports/find-issues.repository';
import { GetCommentsQuery } from './queries/get-comments.query';
import {
GetIssueDecisionsQuery,
SearchDecisionsQuery,
} from './queries/decisions.query';
@Injectable()
export class CommentsService {
@@ -50,6 +58,35 @@ export class CommentsService {
);
}
/** Marks it as a decision of its ticket (`on`) or unmarks it: the team does. */
markDecision(
actorId: string,
commentId: string,
on: boolean,
): Promise<CommandResult> {
return this.commands.execute(
new MarkDecisionCommand(actorId, commentId, on),
);
}
/** A ticket's decisions, oldest first. */
decisions(
viewerId: string | null,
issueId: string,
): Promise<CommentReadModel[]> {
return this.queryBus.execute(new GetIssueDecisionsQuery(viewerId, issueId));
}
/** Decisions by their text: of a project, or of all mine without one. */
searchDecisions(
viewerId: string | null,
filter: { projectId?: string; search?: string; limit?: number },
): Promise<DecisionReadModel[]> {
return this.queryBus.execute(
new SearchDecisionsQuery(viewerId, { limit: 20, ...filter }),
);
}
delete(actorId: string, commentId: string): Promise<CommandResult> {
return this.commands.execute(new DeleteCommentCommand(actorId, commentId));
}

View File

@@ -30,6 +30,9 @@ export class CommentAddedEventHandler
replyTo: comment.replyTo ?? null,
deleted: false,
reactions: [],
decision: false,
decidedBy: null,
decidedAt: null,
createdAt: comment.createdAt,
editedAt: null,
version: versionOf(event),

View File

@@ -0,0 +1,30 @@
import { EventsHandler, IEventHandler } from '@nestjs/cqrs';
import { CommentDecisionMarkedEvent } from '@task-stream/api/tracker/domain';
import { UpsertMaterializedCommentRepository } from '../ports/upsert-materialized-comment.repository';
import { UserDirectory } from '../ports/user-directory';
import { ReadModelNotifier, versionOf } from './read-model-notifier';
@EventsHandler(CommentDecisionMarkedEvent)
export class CommentDecisionMarkedEventHandler
implements IEventHandler<CommentDecisionMarkedEvent>
{
constructor(
private readonly upsertMaterializedCommentRepository: UpsertMaterializedCommentRepository,
private readonly users: UserDirectory,
private readonly notifier: ReadModelNotifier,
) {}
async handle(event: CommentDecisionMarkedEvent) {
const refs = await this.users.refs([event.actorId]);
await this.upsertMaterializedCommentRepository.upsert({
id: event.aggregateId,
decision: event.decision,
decidedBy: event.decision ? refs.of(event.actorId) : null,
decidedAt: event.decision ? event.occurredAt : null,
version: versionOf(event),
});
await this.notifier.comment(event.aggregateId, event.issueId, event);
// The ticket shows its decisions on top: its page reloads them.
await this.notifier.issue(event.issueId, event);
}
}

View File

@@ -3,6 +3,7 @@ import { CommentAddedEventHandler } from './comment-added.event-handler';
import { CommentDeletedEventHandler } from './comment-deleted.event-handler';
import { CommentEditedEventHandler } from './comment-edited.event-handler';
import { CommentReactedEventHandler } from './comment-reacted.event-handler';
import { CommentDecisionMarkedEventHandler } from './comment-decision-marked.event-handler';
import { IssueVotedEventHandler } from './issue-voted.event-handler';
import { IssueAssignedEventHandler } from './issue-assigned.event-handler';
import { IssueCreatedEventHandler } from './issue-created.event-handler';
@@ -50,6 +51,7 @@ export const EVENT_HANDLERS = [
CommentDeletedEventHandler,
CommentEditedEventHandler,
CommentReactedEventHandler,
CommentDecisionMarkedEventHandler,
IssueVotedEventHandler,
IssueAssignedEventHandler,
IssueCreatedEventHandler,

View File

@@ -0,0 +1,31 @@
import { CommandRunner } from '@task-stream/api/shared/application';
import { Injectable } from '@nestjs/common';
import { QueryBus } from '@nestjs/cqrs';
import { IssueReadModel } from '@task-stream/api/tracker/domain';
import {
GetMyPinsQuery,
IsIssuePinnedQuery,
PinIssueCommand,
} from './saved-filters/pins';
/** Pinned tickets: the few someone keeps at hand, across projects. */
@Injectable()
export class PinsService {
constructor(
private readonly commands: CommandRunner,
private readonly queryBus: QueryBus,
) {}
/** Mine, most recently pinned first. */
mine(ownerId: string): Promise<IssueReadModel[]> {
return this.queryBus.execute(new GetMyPinsQuery(ownerId));
}
isPinned(ownerId: string, issueId: string): Promise<boolean> {
return this.queryBus.execute(new IsIssuePinnedQuery(ownerId, issueId));
}
pin(ownerId: string, issueId: string, pinned: boolean): Promise<void> {
return this.commands.execute(new PinIssueCommand(ownerId, issueId, pinned));
}
}

View File

@@ -0,0 +1,35 @@
import { IssuePriority } from '@task-stream/api/tracker/domain';
/** A project's door for alerts: who knows the token opens bugs. */
export interface AlertIntegrationConfig {
projectId: string;
token: string;
/** Ticket type to open; null: the project's first bug type. */
typeId: string | null;
priority: IssuePriority;
createdAt: string;
}
/** Which ticket a problem opened, and how often it came back. */
export interface AlertTrace {
projectId: string;
fingerprint: string;
issueId: string;
issueKey: string;
count: number;
firstAt: string;
lastAt: string;
/** Last time the ticket was told (so repeats do not flood it). */
notedAt: string;
}
export abstract class AlertIntegrationsRepository {
abstract find(projectId: string): Promise<AlertIntegrationConfig | null>;
abstract save(config: AlertIntegrationConfig): Promise<void>;
abstract remove(projectId: string): Promise<void>;
abstract trace(
projectId: string,
fingerprint: string,
): Promise<AlertTrace | null>;
abstract saveTrace(trace: AlertTrace): Promise<void>;
}

View File

@@ -11,4 +11,14 @@ export abstract class FindCommentsRepository {
/** Comments still standing (deleted ones kept for their replies do not count). */
abstract countByIssue(issueId: string): Promise<number>;
abstract countReplies(commentId: string): Promise<number>;
/**
* Comments marked as decisions (not deleted): of one ticket, or of some
* projects, oldest first for a ticket and most recent first otherwise.
*/
abstract findDecisions(filter: {
issueId?: string;
projectIds?: string[];
search?: string;
limit: number;
}): Promise<CommentReadModel[]>;
}

View File

@@ -0,0 +1,12 @@
import { PinnedIssueReadModel } from '@task-stream/api/tracker/domain';
/** Pinned tickets: one person's shortcuts, no history. */
export abstract class PinsRepository {
/** Most recently pinned first. */
abstract findByOwner(ownerId: string): Promise<PinnedIssueReadModel[]>;
abstract isPinned(ownerId: string, issueId: string): Promise<boolean>;
abstract countByOwner(ownerId: string): Promise<number>;
/** Pinning again keeps the first date. */
abstract pin(pin: PinnedIssueReadModel): Promise<void>;
abstract unpin(ownerId: string, issueId: string): Promise<void>;
}

View File

@@ -0,0 +1,112 @@
import { IQueryHandler, Query, QueryHandler } from '@nestjs/cqrs';
import {
CommentReadModel,
DecisionReadModel,
} from '@task-stream/api/tracker/domain';
import { FindCommentsRepository } from '../ports/find-comments.repository';
import { FindIssuesRepository } from '../ports/find-issues.repository';
import { FindProjectsRepository } from '../ports/find-projects.repository';
import { ReadAccess } from './read-access';
/** The decisions of a ticket, oldest first (shown above its conversation). */
export class GetIssueDecisionsQuery extends Query<CommentReadModel[]> {
constructor(
/** Null: anonymous (public projects only). */
public readonly viewerId: string | null,
public readonly issueId: string,
) {
super();
}
}
/**
* Decisions by their text, most recently decided first: of one project, or
* (without one) of every project the viewer belongs to.
*/
export class SearchDecisionsQuery extends Query<DecisionReadModel[]> {
constructor(
public readonly viewerId: string | null,
public readonly filter: {
projectId?: string;
search?: string;
limit: number;
},
) {
super();
}
}
@QueryHandler(GetIssueDecisionsQuery)
export class GetIssueDecisionsQueryHandler
implements IQueryHandler<GetIssueDecisionsQuery, CommentReadModel[]>
{
constructor(
private readonly findIssues: FindIssuesRepository,
private readonly findComments: FindCommentsRepository,
private readonly access: ReadAccess,
) {}
async execute(query: GetIssueDecisionsQuery): Promise<CommentReadModel[]> {
const issue = this.access.assertFound(
await this.findIssues.findById(query.issueId),
'Issue',
query.issueId,
);
await this.access.assertCanViewIssue(issue, query.viewerId);
return this.findComments.findDecisions({ issueId: issue.id, limit: 50 });
}
}
@QueryHandler(SearchDecisionsQuery)
export class SearchDecisionsQueryHandler
implements IQueryHandler<SearchDecisionsQuery, DecisionReadModel[]>
{
constructor(
private readonly findProjects: FindProjectsRepository,
private readonly findIssues: FindIssuesRepository,
private readonly findComments: FindCommentsRepository,
private readonly access: ReadAccess,
) {}
async execute(query: SearchDecisionsQuery): Promise<DecisionReadModel[]> {
const { filter, viewerId } = query;
let projectIds: string[];
if (filter.projectId) {
await this.access.assertCanView(filter.projectId, viewerId);
projectIds = [filter.projectId];
} else {
if (!viewerId) return [];
projectIds = (await this.findProjects.findByMember(viewerId)).map(
(p) => p.id,
);
}
if (projectIds.length === 0) return [];
const limit = Math.min(filter.limit, 100);
// Read a few more: some may sit in the trash or, for a guest, in
// tickets they do not take part in.
const comments = await this.findComments.findDecisions({
projectIds,
search: filter.search?.trim().slice(0, 200) || undefined,
limit: limit * 2,
});
const issues = new Map(
(
await this.findIssues.findByIds([
...new Set(comments.map((c) => c.issueId)),
])
).map((issue) => [issue.id, issue]),
);
const found: DecisionReadModel[] = [];
for (const comment of comments) {
const issue = issues.get(comment.issueId);
if (!issue || issue.trashedAt) continue;
if (!(await this.access.canViewIssue(issue, viewerId))) continue;
found.push({
comment,
issue: { id: issue.id, key: issue.key, title: issue.title },
});
if (found.length === limit) break;
}
return found;
}
}

View File

@@ -26,6 +26,10 @@ import { GetReleaseNotesDraftQueryHandler } from './get-release-notes-draft.quer
import { GetTrashQueryHandler } from './get-trash.query';
import { GetShowcaseQueryHandler } from './get-showcase.query';
import { SearchMyIssuesQueryHandler } from './search-my-issues.query-handler';
import {
GetIssueDecisionsQueryHandler,
SearchDecisionsQueryHandler,
} from './decisions.query';
export const QUERY_HANDLERS = [
GetPublicProjectsQueryHandler,
@@ -33,6 +37,8 @@ export const QUERY_HANDLERS = [
GetActivityQueryHandler,
GetBoardQueryHandler,
GetCommentsQueryHandler,
GetIssueDecisionsQueryHandler,
SearchDecisionsQueryHandler,
GetIssueQueryHandler,
GetIssuesByIdsQueryHandler,
GetMyProjectsQueryHandler,

View File

@@ -0,0 +1,142 @@
import {
CommandBus,
CqrsModule,
IQueryHandler,
QueryBus,
QueryHandler,
} from '@nestjs/cqrs';
import { Test } from '@nestjs/testing';
import {
BusinessRuleError,
NotFoundError,
} from '@task-stream/api/shared/domain';
import {
IssueReadModel,
PinnedIssueReadModel,
} from '@task-stream/api/tracker/domain';
import { PinsRepository } from '../ports/pins.repository';
import { GetIssuesByIdsQuery } from '../queries/get-issues-by-ids.query';
import {
GetMyPinsQuery,
IsIssuePinnedQuery,
MAX_PINS,
PINS,
PinIssueCommand,
} from './pins';
class InMemoryPins extends PinsRepository {
rows: PinnedIssueReadModel[] = [];
async findByOwner(ownerId: string) {
return this.rows
.filter((p) => p.ownerId === ownerId)
.sort((a, b) => b.pinnedAt.localeCompare(a.pinnedAt));
}
async isPinned(ownerId: string, issueId: string) {
return this.rows.some(
(p) => p.ownerId === ownerId && p.issueId === issueId,
);
}
async countByOwner(ownerId: string) {
return this.rows.filter((p) => p.ownerId === ownerId).length;
}
async pin(pin: PinnedIssueReadModel) {
if (!(await this.isPinned(pin.ownerId, pin.issueId))) this.rows.push(pin);
}
async unpin(ownerId: string, issueId: string) {
this.rows = this.rows.filter(
(p) => !(p.ownerId === ownerId && p.issueId === issueId),
);
}
}
/** What each person can read: `ana` all but `hidden`, in the trash `old`. */
const issues = new Map<string, IssueReadModel>();
const readable = (viewerId: string | null, id: string) =>
viewerId === 'ana' && id !== 'hidden';
@QueryHandler(GetIssuesByIdsQuery)
class FakeIssuesByIds implements IQueryHandler<GetIssuesByIdsQuery> {
async execute(query: GetIssuesByIdsQuery) {
return query.ids
.filter((id) => readable(query.viewerId, id))
.map(
(id) =>
issues.get(id) ??
({
id,
trashedAt: id === 'old' ? '2026-01-01' : null,
} as IssueReadModel),
);
}
}
async function setup() {
const rows = new InMemoryPins();
const moduleRef = await Test.createTestingModule({
imports: [CqrsModule.forRoot()],
providers: [
...PINS,
FakeIssuesByIds,
{ provide: PinsRepository, useValue: rows },
],
}).compile();
await moduleRef.init();
return {
rows,
commands: moduleRef.get(CommandBus),
queries: moduleRef.get(QueryBus),
};
}
describe('pinned tickets', () => {
it('pins, lists most recent first and unpins', async () => {
const { commands, queries } = await setup();
await commands.execute(new PinIssueCommand('ana', 'a', true));
await new Promise((r) => setTimeout(r, 2));
await commands.execute(new PinIssueCommand('ana', 'b', true));
// Twice is still once.
await commands.execute(new PinIssueCommand('ana', 'b', true));
const mine = await queries.execute(new GetMyPinsQuery('ana'));
expect(mine.map((i: IssueReadModel) => i.id)).toEqual(['b', 'a']);
expect(await queries.execute(new IsIssuePinnedQuery('ana', 'a'))).toBe(
true,
);
await commands.execute(new PinIssueCommand('ana', 'a', false));
expect(await queries.execute(new IsIssuePinnedQuery('ana', 'a'))).toBe(
false,
);
});
it('does not pin what I cannot read, nor what is in the trash', async () => {
const { commands } = await setup();
await expect(
commands.execute(new PinIssueCommand('ana', 'hidden', true)),
).rejects.toBeInstanceOf(NotFoundError);
await expect(
commands.execute(new PinIssueCommand('ana', 'old', true)),
).rejects.toBeInstanceOf(NotFoundError);
});
it('keeps a pin to a trashed ticket but does not list it', async () => {
const { commands, queries, rows } = await setup();
await commands.execute(new PinIssueCommand('ana', 'a', true));
await rows.pin({ ownerId: 'ana', issueId: 'old', pinnedAt: '2026-01-02' });
const mine = await queries.execute(new GetMyPinsQuery('ana'));
expect(mine.map((i: IssueReadModel) => i.id)).toEqual(['a']);
expect(rows.rows).toHaveLength(2);
});
it(`stops at ${MAX_PINS}, after forgetting the ones out of reach`, async () => {
const { commands, rows } = await setup();
for (let i = 0; i < MAX_PINS - 1; i++)
await rows.pin({ ownerId: 'ana', issueId: `t${i}`, pinnedAt: `${i}` });
await rows.pin({ ownerId: 'ana', issueId: 'hidden', pinnedAt: 'x' });
// Full, but one is out of reach: it makes room.
await commands.execute(new PinIssueCommand('ana', 'new', true));
expect(await rows.isPinned('ana', 'hidden')).toBe(false);
expect(await rows.countByOwner('ana')).toBe(MAX_PINS);
await expect(
commands.execute(new PinIssueCommand('ana', 'one-more', true)),
).rejects.toBeInstanceOf(BusinessRuleError);
});
});

View File

@@ -0,0 +1,150 @@
import {
Command,
CommandHandler,
ICommandHandler,
IQueryHandler,
Query,
QueryBus,
QueryHandler,
} from '@nestjs/cqrs';
import {
BusinessRuleError,
NotFoundError,
} from '@task-stream/api/shared/domain';
import { IssueReadModel } from '@task-stream/api/tracker/domain';
import { PinsRepository } from '../ports/pins.repository';
import { GetIssuesByIdsQuery } from '../queries/get-issues-by-ids.query';
/*
* Pinned tickets: the few someone keeps at hand, on their home page and in
* the search palette. Private, across projects, no history (like the views
* of «my work»).
*/
/** A person keeps at most this many. */
export const MAX_PINS = 20;
export class PinIssueCommand extends Command<void> {
constructor(
public readonly ownerId: string,
public readonly issueId: string,
/** False: unpin. */
public readonly pinned: boolean,
) {
super();
}
}
/** My pinned tickets that I can still read and are not in the trash. */
export class GetMyPinsQuery extends Query<IssueReadModel[]> {
constructor(public readonly ownerId: string) {
super();
}
}
export class IsIssuePinnedQuery extends Query<boolean> {
constructor(
public readonly ownerId: string,
public readonly issueId: string,
) {
super();
}
}
@CommandHandler(PinIssueCommand)
export class PinIssueCommandHandler
implements ICommandHandler<PinIssueCommand>
{
constructor(
private readonly pins: PinsRepository,
private readonly queryBus: QueryBus,
) {}
async execute(command: PinIssueCommand): Promise<void> {
const { ownerId, issueId } = command;
if (!command.pinned) return this.pins.unpin(ownerId, issueId);
const [issue] = await this.queryBus.execute(
new GetIssuesByIdsQuery(ownerId, [issueId]),
);
// A ticket I cannot read does not exist, as far as I know.
if (!issue || issue.trashedAt) throw new NotFoundError('Issue', issueId);
if (await this.pins.isPinned(ownerId, issueId)) return;
if (
(await this.pins.countByOwner(ownerId)) >= MAX_PINS &&
(await this.dropUnreadable(ownerId)) >= MAX_PINS
)
throw new BusinessRuleError(
`You already keep ${MAX_PINS} pinned tickets; unpin one first`,
);
await this.pins.pin({
ownerId,
issueId,
pinnedAt: new Date().toISOString(),
});
}
/**
* Forgets the pins of tickets I can no longer read (I left the project,
* it was deleted) so they do not hold a place; returns how many remain.
*/
private async dropUnreadable(ownerId: string): Promise<number> {
const pins = await this.pins.findByOwner(ownerId);
const readable = new Set(
(
await this.queryBus.execute(
new GetIssuesByIdsQuery(
ownerId,
pins.map((p) => p.issueId),
),
)
).map((issue) => issue.id),
);
for (const pin of pins)
if (!readable.has(pin.issueId))
await this.pins.unpin(ownerId, pin.issueId);
return readable.size;
}
}
@QueryHandler(GetMyPinsQuery)
export class GetMyPinsQueryHandler
implements IQueryHandler<GetMyPinsQuery, IssueReadModel[]>
{
constructor(
private readonly pins: PinsRepository,
private readonly queryBus: QueryBus,
) {}
async execute(query: GetMyPinsQuery): Promise<IssueReadModel[]> {
const pins = await this.pins.findByOwner(query.ownerId);
if (pins.length === 0) return [];
const issues: IssueReadModel[] = await this.queryBus.execute(
new GetIssuesByIdsQuery(
query.ownerId,
pins.map((p) => p.issueId),
),
);
// In the trash or out of reach: kept, in case it comes back.
const byId = new Map(
issues.filter((i) => !i.trashedAt).map((i) => [i.id, i]),
);
return pins.flatMap((p) => byId.get(p.issueId) ?? []);
}
}
@QueryHandler(IsIssuePinnedQuery)
export class IsIssuePinnedQueryHandler
implements IQueryHandler<IsIssuePinnedQuery, boolean>
{
constructor(private readonly pins: PinsRepository) {}
execute(query: IsIssuePinnedQuery): Promise<boolean> {
return this.pins.isPinned(query.ownerId, query.issueId);
}
}
export const PINS = [
PinIssueCommandHandler,
GetMyPinsQueryHandler,
IsIssuePinnedQueryHandler,
];

View File

@@ -35,6 +35,10 @@ import {
import { SAVED_FILTERS } from './saved-filters/saved-filters';
import { MY_VIEWS } from './saved-filters/my-views';
import { PINS } from './saved-filters/pins';
import { ALERT_HANDLERS } from './alerts/alerts';
import { AlertsService } from './alerts.service';
import { PinsService } from './pins.service';
import { SendDueRemindersCommandHandler } from './notifications/reminders';
import { SavedFiltersService } from './saved-filters.service';
import { SprintsService } from './sprints.service';
@@ -78,6 +82,8 @@ const SERVICES = [
BlockersService,
NotificationsService,
SavedFiltersService,
PinsService,
AlertsService,
SprintsService,
GitService,
WebhooksService,
@@ -108,6 +114,8 @@ const SERVICES = [
...NOTIFICATIONS,
...SAVED_FILTERS,
...MY_VIEWS,
...PINS,
...ALERT_HANDLERS,
...GIT_HANDLERS,
...WEBHOOK_HANDLERS,
...TRANSFER_HANDLERS,

View File

@@ -5,6 +5,7 @@ import {
assertThat,
} from '@task-stream/api/shared/domain';
import { CommentAddedEvent } from '../events/comment-added.event';
import { CommentDecisionMarkedEvent } from '../events/comment-decision-marked.event';
import { CommentDeletedEvent } from '../events/comment-deleted.event';
import { CommentEditedEvent } from '../events/comment-edited.event';
import { CommentReactedEvent } from '../events/comment-reacted.event';
@@ -41,6 +42,8 @@ export class Comment extends VersionedAggregateRoot implements CommentProps {
public replyTo: string | null = null;
/** Who chose each emoji (only the ones somebody chose). */
public reactions: Record<string, string[]> = {};
/** What was decided on the ticket: shown above it and searchable. */
public decision = false;
public deleted = false;
public createdAt!: string;
public editedAt: string | null = null;
@@ -93,6 +96,24 @@ export class Comment extends VersionedAggregateRoot implements CommentProps {
);
}
/**
* Marks it as a decision of the ticket (`on`) or unmarks it; repeating it
* changes nothing. Who may do it (the team) is the caller's check.
*/
markDecision(actorId: string, on: boolean): void {
this.assertAlive();
if (on === this.decision) return;
this.apply(
new CommentDecisionMarkedEvent(
actorId,
this.id,
this.projectId,
this.issueId,
on,
),
);
}
/** @param canModerate whether the actor manages the project (may delete others' comments). */
delete(actorId: string, canModerate: boolean): void {
this.assertAlive();
@@ -121,6 +142,10 @@ export class Comment extends VersionedAggregateRoot implements CommentProps {
this.reactions = event.reactions;
}
onCommentDecisionMarkedEvent(event: CommentDecisionMarkedEvent): void {
this.decision = event.decision;
}
onCommentDeletedEvent(): void {
this.deleted = true;
}

View File

@@ -0,0 +1,17 @@
import { AutowiredEvent } from '@task-stream/api/shared/domain';
import { TrackerEvent } from './tracker.event';
/** A comment was marked as a decision of the ticket, or unmarked. */
@AutowiredEvent
export class CommentDecisionMarkedEvent extends TrackerEvent {
constructor(
actorId: string,
commentId: string,
projectId: string,
readonly issueId: string,
/** False: it is no longer a decision. */
readonly decision: boolean,
) {
super(actorId, commentId, projectId);
}
}

View File

@@ -15,6 +15,7 @@ export * from './issue-release-changed.event';
export * from './comment-added.event';
export * from './comment-edited.event';
export * from './comment-reacted.event';
export * from './comment-decision-marked.event';
export * from './comment-deleted.event';
export * from './release-created.event';
export * from './release-updated.event';

View File

@@ -21,7 +21,18 @@ export class CommentReadModel {
deleted!: boolean;
/** In the order they are offered; only the ones somebody chose. */
reactions!: CommentReactionReadModel[];
/** Marked as a decision of the ticket. */
decision!: boolean;
/** Who marked it and when (null when it is not a decision). */
decidedBy!: UserRefReadModel | null;
decidedAt!: string | null;
createdAt!: string;
editedAt!: string | null;
version!: number;
}
/** A decision with the ticket it belongs to, for searching across tickets. */
export class DecisionReadModel {
comment!: CommentReadModel;
issue!: { id: string; key: string; title: string };
}

View File

@@ -56,3 +56,10 @@ export class MyViewReadModel {
criteria!: MyViewCriteriaReadModel;
createdAt!: string;
}
/** A ticket someone keeps at hand: private to them, no history. */
export class PinnedIssueReadModel {
ownerId!: string;
issueId!: string;
pinnedAt!: string;
}

View File

@@ -0,0 +1,65 @@
import { Injectable } from '@nestjs/common';
import { InjectModel } from '@nestjs/mongoose';
import {
AlertIntegrationConfig,
AlertIntegrationsRepository,
AlertTrace,
} from '@task-stream/api/tracker/application';
import { Model } from 'mongoose';
import { ReadModels } from './read-model.schemas';
type Stored<T> = T & { _id?: unknown };
@Injectable()
export class MongoAlertIntegrationsRepository extends AlertIntegrationsRepository {
constructor(
@InjectModel(ReadModels.AlertIntegration)
private readonly configs: Model<Stored<AlertIntegrationConfig>>,
@InjectModel(ReadModels.AlertTrace)
private readonly traces: Model<Stored<AlertTrace>>,
) {
super();
}
async find(projectId: string): Promise<AlertIntegrationConfig | null> {
const doc = await this.configs
.findOne({ projectId })
.lean<Stored<AlertIntegrationConfig>>();
if (!doc) return null;
const { _id: _ignored, ...config } = doc;
return config;
}
async save(config: AlertIntegrationConfig): Promise<void> {
await this.configs.updateOne(
{ projectId: config.projectId },
{ $set: config },
{ upsert: true },
);
}
async remove(projectId: string): Promise<void> {
// The traces stay: turned on again, repeats still find their bug.
await this.configs.deleteOne({ projectId });
}
async trace(
projectId: string,
fingerprint: string,
): Promise<AlertTrace | null> {
const doc = await this.traces
.findOne({ projectId, fingerprint })
.lean<Stored<AlertTrace>>();
if (!doc) return null;
const { _id: _ignored, ...trace } = doc;
return trace;
}
async saveTrace(trace: AlertTrace): Promise<void> {
await this.traces.updateOne(
{ projectId: trace.projectId, fingerprint: trace.fingerprint },
{ $set: trace },
{ upsert: true },
);
}
}

View File

@@ -51,6 +51,9 @@ const commentFromDoc = (doc: CommentDoc): CommentReadModel => ({
replyTo: doc.replyTo ?? null,
deleted: doc.deleted ?? false,
reactions: doc.reactions ?? [],
decision: doc.decision ?? false,
decidedBy: doc.decidedBy ?? null,
decidedAt: doc.decidedAt ?? null,
});
const escapeRegex = (text: string) =>
@@ -545,6 +548,28 @@ export class MongoFindCommentsRepository extends FindCommentsRepository {
countReplies(commentId: string): Promise<number> {
return this.comments.countDocuments({ replyTo: commentId });
}
async findDecisions(filter: {
issueId?: string;
projectIds?: string[];
search?: string;
limit: number;
}): Promise<CommentReadModel[]> {
const query: Record<string, unknown> = {
decision: true,
deleted: { $ne: true },
};
if (filter.issueId) query['issueId'] = filter.issueId;
if (filter.projectIds) query['projectId'] = { $in: filter.projectIds };
if (filter.search)
query['body'] = { $regex: escapeRegex(filter.search), $options: 'i' };
const docs = await this.comments
.find(query)
.sort(filter.issueId ? { createdAt: 1 } : { decidedAt: -1 })
.limit(filter.limit)
.lean<CommentDoc[]>();
return docs.map(commentFromDoc);
}
}
@Injectable()

View File

@@ -0,0 +1,48 @@
import { Injectable } from '@nestjs/common';
import { InjectModel } from '@nestjs/mongoose';
import { PinsRepository } from '@task-stream/api/tracker/application';
import { PinnedIssueReadModel } from '@task-stream/api/tracker/domain';
import { Model } from 'mongoose';
import { PinnedIssueDoc, ReadModels } from './read-model.schemas';
@Injectable()
export class MongoPinsRepository extends PinsRepository {
constructor(
@InjectModel(ReadModels.PinnedIssue)
private readonly pins: Model<PinnedIssueDoc>,
) {
super();
}
async findByOwner(ownerId: string): Promise<PinnedIssueReadModel[]> {
const docs = await this.pins
.find({ ownerId })
.sort({ pinnedAt: -1 })
.lean<PinnedIssueDoc[]>();
return docs.map(({ ownerId, issueId, pinnedAt }) => ({
ownerId,
issueId,
pinnedAt,
}));
}
async isPinned(ownerId: string, issueId: string): Promise<boolean> {
return !!(await this.pins.exists({ ownerId, issueId }));
}
countByOwner(ownerId: string): Promise<number> {
return this.pins.countDocuments({ ownerId });
}
async pin(pin: PinnedIssueReadModel): Promise<void> {
await this.pins.updateOne(
{ ownerId: pin.ownerId, issueId: pin.issueId },
{ $setOnInsert: pin },
{ upsert: true },
);
}
async unpin(ownerId: string, issueId: string): Promise<void> {
await this.pins.deleteOne({ ownerId, issueId });
}
}

View File

@@ -13,6 +13,7 @@ import {
ReleaseReadModel,
SavedFilterReadModel,
MyViewReadModel,
PinnedIssueReadModel,
SprintReadModel,
implicitRank,
} from '@task-stream/api/tracker/domain';
@@ -42,6 +43,7 @@ export type TimelineDoc = Doc<IssueTimelineEntry>;
export type NotificationDoc = Doc<NotificationReadModel>;
export type SavedFilterDoc = Doc<SavedFilterReadModel>;
export type MyViewDoc = Doc<MyViewReadModel>;
export type PinnedIssueDoc = PinnedIssueReadModel & { _id?: Types.ObjectId };
export type OutgoingWebhookDoc = Doc<OutgoingWebhook>;
export type AutomationDoc = Doc<Automation>;
export interface AutomationRunDoc {
@@ -104,6 +106,9 @@ export const ReadModels = {
NotificationSettings: 'NotificationSettings',
SavedFilter: 'SavedFilter',
MyView: 'MyView',
PinnedIssue: 'PinnedIssue',
AlertIntegration: 'AlertIntegration',
AlertTrace: 'AlertTrace',
Sprint: 'SprintReadModel',
GitIntegration: 'GitIntegration',
OutgoingWebhook: 'OutgoingWebhook',
@@ -236,6 +241,10 @@ class CommentDocument {
}
export const CommentSchema = SchemaFactory.createForClass(CommentDocument);
CommentSchema.index({ issueId: 1, createdAt: 1 });
CommentSchema.index(
{ projectId: 1, decidedAt: -1 },
{ partialFilterExpression: { decision: true } },
);
@Schema({ ...LOOSE, collection: 'releases_view' })
class ReleaseDocument {
@@ -322,6 +331,35 @@ class MyViewDocument {
}
export const MyViewSchema = SchemaFactory.createForClass(MyViewDocument);
@Schema({ ...LOOSE, collection: 'pinned_issues' })
class PinnedIssueDocument {
@Prop({ type: String }) ownerId!: string;
@Prop({ type: String }) issueId!: string;
@Prop({ type: String }) pinnedAt!: string;
}
export const PinnedIssueSchema =
SchemaFactory.createForClass(PinnedIssueDocument);
PinnedIssueSchema.index({ ownerId: 1, issueId: 1 }, { unique: true });
@Schema({ ...LOOSE, collection: 'alert_integrations' })
class AlertIntegrationDocument {
@Prop({ type: String, required: true, unique: true }) projectId!: string;
@Prop({ type: String }) token!: string;
}
export const AlertIntegrationSchema = SchemaFactory.createForClass(
AlertIntegrationDocument,
);
@Schema({ ...LOOSE, collection: 'alert_traces' })
class AlertTraceDocument {
@Prop({ type: String }) projectId!: string;
@Prop({ type: String }) fingerprint!: string;
@Prop({ type: String }) issueId!: string;
}
export const AlertTraceSchema =
SchemaFactory.createForClass(AlertTraceDocument);
AlertTraceSchema.index({ projectId: 1, fingerprint: 1 }, { unique: true });
@Schema({ ...LOOSE, collection: 'git_integrations' })
class GitIntegrationDocument {
/** The project's id. */
@@ -424,6 +462,9 @@ export const READ_MODEL_SCHEMAS = [
{ name: ReadModels.NotificationSettings, schema: NotificationSettingsSchema },
{ name: ReadModels.SavedFilter, schema: SavedFilterSchema },
{ name: ReadModels.MyView, schema: MyViewSchema },
{ name: ReadModels.PinnedIssue, schema: PinnedIssueSchema },
{ name: ReadModels.AlertIntegration, schema: AlertIntegrationSchema },
{ name: ReadModels.AlertTrace, schema: AlertTraceSchema },
{ name: ReadModels.CalendarFeed, schema: CalendarFeedSchema },
{ name: ReadModels.DashboardShare, schema: DashboardShareSchema },
{ name: ReadModels.PushSubscription, schema: PushSubscriptionSchema },

View File

@@ -13,6 +13,8 @@ import {
PushSender,
SavedFiltersRepository,
MyViewsRepository,
PinsRepository,
AlertIntegrationsRepository,
GitIntegrationsRepository,
OutgoingWebhooksRepository,
AutomationsRepository,
@@ -78,6 +80,8 @@ import { MongoPushSubscriptionsRepository } from './mongo/push-subscriptions.rep
import { WebPushSender } from './push/web-push.sender';
import { MongoSavedFiltersRepository } from './mongo/saved-filters.repository';
import { MongoMyViewsRepository } from './mongo/my-views.repository';
import { MongoPinsRepository } from './mongo/pins.repository';
import { MongoAlertIntegrationsRepository } from './mongo/alert-integrations.repository';
import { MongoGitIntegrationsRepository } from './mongo/git-integrations.repository';
import { MongoOutgoingWebhooksRepository } from './mongo/outgoing-webhooks.repository';
import { HttpWebhookSender } from './webhooks/http-webhook.sender';
@@ -154,6 +158,11 @@ const PORTS = [
{ provide: PushSender, useClass: WebPushSender },
{ provide: SavedFiltersRepository, useClass: MongoSavedFiltersRepository },
{ provide: MyViewsRepository, useClass: MongoMyViewsRepository },
{ provide: PinsRepository, useClass: MongoPinsRepository },
{
provide: AlertIntegrationsRepository,
useClass: MongoAlertIntegrationsRepository,
},
{
provide: GitIntegrationsRepository,
useClass: MongoGitIntegrationsRepository,

View File

@@ -0,0 +1,3 @@
/** Where monitoring tools send alerts: the token in the URL is the secret. */
export const alertWebhookPath = (projectId: string, token: string) =>
`/api/webhooks/alerts/${projectId}/${token}`;

View File

@@ -0,0 +1,71 @@
import { Args, ID, Mutation, Query, Resolver } from '@nestjs/graphql';
import { User } from '@task-stream/api/identity/domain';
import { CurrentUser } from '@task-stream/api/identity/presenters';
import {
AlertIntegration,
AlertsService,
} from '@task-stream/api/tracker/application';
import { alertWebhookPath } from '../common/alert-webhook';
import { AlertSettingsInput } from './inputs';
import { AlertIntegrationGql } from './types';
/** Alerts → bugs: the project's secret URL (owners and admins configure it). */
@Resolver(() => AlertIntegrationGql)
export class AlertResolver {
constructor(private readonly alerts: AlertsService) {}
@Query(() => AlertIntegrationGql)
async alertIntegration(
@CurrentUser() me: User,
@Args('projectId', { type: () => ID }) projectId: string,
) {
return withPath(projectId, await this.alerts.get(me.id, projectId));
}
@Mutation(() => AlertIntegrationGql, {
description:
'Turn alerts → bugs on, or change the type and priority it opens (the URL stays)',
})
async saveAlertIntegration(
@CurrentUser() me: User,
@Args('projectId', { type: () => ID }) projectId: string,
@Args('input') input: AlertSettingsInput,
) {
return withPath(
projectId,
await this.alerts.save(me.id, projectId, {
typeId: input.typeId,
priority: input.priority,
}),
);
}
@Mutation(() => AlertIntegrationGql, {
description: 'A new secret URL; the old one stops working at once',
})
async rotateAlertToken(
@CurrentUser() me: User,
@Args('projectId', { type: () => ID }) projectId: string,
) {
return withPath(projectId, await this.alerts.rotateToken(me.id, projectId));
}
@Mutation(() => Boolean, {
description: 'Turn it off; the bugs it opened stay',
})
async disableAlertIntegration(
@CurrentUser() me: User,
@Args('projectId', { type: () => ID }) projectId: string,
) {
await this.alerts.disable(me.id, projectId);
return true;
}
}
function withPath(projectId: string, integration: AlertIntegration) {
const { token, ...rest } = integration;
return {
...rest,
webhookPath: token ? alertWebhookPath(projectId, token) : null,
};
}

View File

@@ -1,8 +1,12 @@
import { Args, ID, Mutation, Resolver } from '@nestjs/graphql';
import { Args, ID, Int, Mutation, Query, Resolver } from '@nestjs/graphql';
import { User } from '@task-stream/api/identity/domain';
import { CurrentUser } from '@task-stream/api/identity/presenters';
import {
CurrentUser,
OptionalAuth,
} from '@task-stream/api/identity/presenters';
import { CommentsService } from '@task-stream/api/tracker/application';
import { CommandResultGql, CommentGql } from './types';
import { viewerOf } from '../common/viewer';
import { CommandResultGql, CommentGql, DecisionGql } from './types';
@Resolver(() => CommentGql)
export class CommentResolver {
@@ -43,6 +47,37 @@ export class CommentResolver {
return this.comments.react(me.id, commentId, emoji, on);
}
@Mutation(() => CommandResultGql, {
description:
'Mark a comment as a decision of its ticket, or unmark it (on: false). Whoever can change the ticket',
})
markDecision(
@CurrentUser() me: User,
@Args('commentId', { type: () => ID }) commentId: string,
@Args('on', { defaultValue: true }) on: boolean,
) {
return this.comments.markDecision(me.id, commentId, on);
}
@OptionalAuth()
@Query(() => [DecisionGql], {
description:
'Decisions by their text, most recent first: of a project, or of all my projects without one',
})
decisions(
@CurrentUser() me: User | undefined,
@Args('projectId', { type: () => ID, nullable: true })
projectId: string | null,
@Args('search', { nullable: true }) search: string | null,
@Args('limit', { type: () => Int, defaultValue: 20 }) limit: number,
) {
return this.comments.searchDecisions(viewerOf(me), {
projectId: projectId ?? undefined,
search: search ?? undefined,
limit,
});
}
@Mutation(() => CommandResultGql, { description: 'Author or project admin' })
deleteComment(
@CurrentUser() me: User,

View File

@@ -880,3 +880,19 @@ export class ShareDashboardInput {
@IsBoolean()
renew?: boolean;
}
@InputType()
export class AlertSettingsInput {
@Field(() => ID, {
nullable: true,
description: "Ticket type to open; null: the project's first bug type",
})
@IsOptional()
@IsString()
typeId?: string | null;
@Field(() => IssuePriority, { nullable: true })
@IsOptional()
@IsEnum(IssuePriority)
priority?: IssuePriority;
}

View File

@@ -17,6 +17,7 @@ import {
BlockersService,
CommentsService,
IssuesService,
PinsService,
PresenceService,
WorkflowsService,
} from '@task-stream/api/tracker/application';
@@ -43,6 +44,7 @@ import {
ActivityGql,
BulkResultGql,
CommandResultGql,
CommentGql,
CommentPageGql,
EpicGql,
CustomFieldValueGql,
@@ -60,6 +62,7 @@ export class IssueResolver {
private readonly workflows: WorkflowsService,
private readonly blockers: BlockersService,
private readonly presence: PresenceService,
private readonly pins: PinsService,
) {}
@Mutation(() => [PresenceGql], {
@@ -245,6 +248,26 @@ export class IssueResolver {
return !!me && issue.voterIds.includes(me.id);
}
@ResolveField(() => [CommentGql], {
description: 'Its comments marked as decisions, oldest first',
})
decisions(
@CurrentUser() me: User | undefined,
@Parent() issue: IssueReadModel,
) {
return this.commentsService.decisions(viewerOf(me), issue.id);
}
@ResolveField(() => Boolean, {
description: 'Whether I pinned it (false without a token)',
})
pinned(
@CurrentUser() me: User | undefined,
@Parent() issue: IssueReadModel,
): Promise<boolean> | boolean {
return !!me && this.pins.isPinned(me.id, issue.id);
}
@ResolveField(() => [IssueGql], { description: 'Linked tickets I can see' })
linkedIssues(
@CurrentUser() me: User | undefined,
@@ -317,6 +340,35 @@ export class IssueResolver {
return this.issuesService.watch(me.id, issueId, true);
}
@Query(() => [IssueGql], {
description:
'My pinned tickets, most recently pinned first (trashed or out of reach left out)',
})
myPins(@CurrentUser() me: User) {
return this.pins.mine(me.id);
}
@Mutation(() => Boolean, {
description:
'Pin a ticket: keep it at hand on my home page and in search (private, at most 20)',
})
async pinIssue(
@CurrentUser() me: User,
@Args('issueId', { type: () => ID }) issueId: string,
): Promise<boolean> {
await this.pins.pin(me.id, issueId, true);
return true;
}
@Mutation(() => Boolean, { description: 'Unpin a ticket' })
async unpinIssue(
@CurrentUser() me: User,
@Args('issueId', { type: () => ID }) issueId: string,
): Promise<boolean> {
await this.pins.pin(me.id, issueId, false);
return true;
}
@Mutation(() => CommandResultGql, { description: 'Stop following a ticket' })
unwatchIssue(
@CurrentUser() me: User,

View File

@@ -548,6 +548,16 @@ export class CommentGql {
description: 'In the order they are offered; only the ones somebody chose',
})
reactions!: CommentReactionGql[];
@Field({
description: 'Marked as a decision of the ticket (shown above it)',
})
decision!: boolean;
@Field(() => UserRefGql, {
nullable: true,
description: 'Who marked it as a decision',
})
decidedBy!: UserRefGql | null;
@Field(() => String, { nullable: true }) decidedAt!: string | null;
@Field() createdAt!: string;
@Field(() => String, { nullable: true }) editedAt!: string | null;
@Field(() => Int) version!: number;
@@ -559,6 +569,14 @@ export class CommentPageGql {
@Field(() => Int) total!: number;
}
@ObjectType('Decision', {
description: 'A comment marked as a decision, with its ticket',
})
export class DecisionGql {
@Field(() => CommentGql) comment!: CommentGql;
@Field(() => IssueParentGql) issue!: IssueParentGql;
}
@ObjectType('Release')
export class ReleaseGql {
@Field(() => ID) id!: string;
@@ -1310,3 +1328,23 @@ export class SharedDashboardGql {
@Field(() => [ActivityDayGql], { nullable: true })
activity!: ActivityDayGql[] | null;
}
@ObjectType('AlertIntegration', {
description:
'Alerts → bugs: the secret URL monitoring tools call to open bugs',
})
export class AlertIntegrationGql {
@Field() enabled!: boolean;
@Field(() => String, {
nullable: true,
description:
'Path of the URL on this API, secret token included; only owners and admins see it',
})
webhookPath!: string | null;
@Field(() => ID, {
nullable: true,
description: "Ticket type it opens; null: the project's first bug type",
})
typeId!: string | null;
@Field(() => IssuePriority) priority!: IssuePriority;
}

View File

@@ -0,0 +1,166 @@
import {
Body,
Controller,
Delete,
Get,
Headers,
HttpCode,
Param,
Post,
Put,
} from '@nestjs/common';
import {
ApiBody,
ApiForbiddenResponse,
ApiNoContentResponse,
ApiOkResponse,
ApiOperation,
ApiParam,
ApiTags,
} from '@nestjs/swagger';
import { User } from '@task-stream/api/identity/domain';
import {
ApiPireonAuth,
CurrentUser,
Public,
} from '@task-stream/api/identity/presenters';
import {
ApiCommandErrors,
ApiReadErrors,
} from '@task-stream/api/shared/presenters';
import {
AlertIntegration,
AlertsService,
} from '@task-stream/api/tracker/application';
import { alertWebhookPath } from '../common/alert-webhook';
import { AlertSettingsDto } from './dto/requests.dto';
import { AlertIntegrationDto, AlertOutcomeDto } from './dto/responses.dto';
import { Refs } from './refs';
const PROJECT = ApiParam({
name: 'project',
description: 'Project id or key',
example: 'WEB',
});
/**
* Alerts → bugs: Sentry, Uptime Kuma, Grafana or anything that can call a
* URL opens bugs in the project; repeats of the same problem go to the bug
* it opened while that one is open.
*/
@ApiTags('Alerts')
@Controller()
export class AlertsController {
constructor(
private readonly alerts: AlertsService,
private readonly refs: Refs,
) {}
@Get('projects/:project/alert-integration')
@ApiPireonAuth()
@PROJECT
@ApiOperation({
summary: 'Whether alerts open bugs, and the secret URL (for admins)',
})
@ApiOkResponse({ type: AlertIntegrationDto })
@ApiReadErrors()
async integration(
@CurrentUser() me: User,
@Param('project') project: string,
) {
const projectId = await this.refs.projectId(me.id, project);
return withPath(projectId, await this.alerts.get(me.id, projectId));
}
@Put('projects/:project/alert-integration')
@ApiPireonAuth()
@PROJECT
@ApiOperation({
summary: 'Turn alerts → bugs on, or change what it opens (owners, admins)',
description: 'The URL stays the same; rotate it with POST …/token.',
})
@ApiOkResponse({ type: AlertIntegrationDto })
@ApiCommandErrors()
async save(
@CurrentUser() me: User,
@Param('project') project: string,
@Body() body: AlertSettingsDto,
) {
const projectId = await this.refs.projectId(me.id, project);
return withPath(
projectId,
await this.alerts.save(me.id, projectId, {
typeId: body.typeId,
priority: body.priority,
}),
);
}
@Post('projects/:project/alert-integration/token')
@HttpCode(200)
@ApiPireonAuth()
@PROJECT
@ApiOperation({
summary: 'A new secret URL; the old one stops working at once',
})
@ApiOkResponse({ type: AlertIntegrationDto })
@ApiCommandErrors()
async rotate(@CurrentUser() me: User, @Param('project') project: string) {
const projectId = await this.refs.projectId(me.id, project);
return withPath(projectId, await this.alerts.rotateToken(me.id, projectId));
}
@Delete('projects/:project/alert-integration')
@HttpCode(204)
@ApiPireonAuth()
@PROJECT
@ApiOperation({ summary: 'Turn it off; the bugs it opened stay' })
@ApiNoContentResponse()
@ApiCommandErrors()
async disable(@CurrentUser() me: User, @Param('project') project: string) {
await this.alerts.disable(me.id, await this.refs.projectId(me.id, project));
}
@Public()
@Post('webhooks/alerts/:project/:token')
@HttpCode(200)
@PROJECT
@ApiParam({ name: 'token', description: 'The secret of the URL' })
@ApiOperation({
summary: 'Receive an alert (called by the monitoring tool)',
description:
'Understands Sentry (internal integration or webhooks plugin), Uptime Kuma, Prometheus Alertmanager and Grafana; anything else sends JSON with `title`, and optionally `fingerprint`, `description`, `url` and `resolved`. A new problem opens a bug as TaskStream; the same one again goes to that bug while it is open (a comment at most every 30 minutes); «resolved» is said on it.',
})
@ApiBody({
required: false,
schema: {
type: 'object',
example: {
title: 'La cola de correos está atascada',
fingerprint: 'mail-queue',
description: '1.200 mensajes esperando',
url: 'https://grafana.example.com/d/mail',
},
},
})
@ApiOkResponse({ type: AlertOutcomeDto })
@ApiForbiddenResponse({
description: 'Unknown project, alerts off or bad token',
})
receive(
@Param('project') project: string,
@Param('token') token: string,
@Body() body: unknown,
@Headers() headers: Record<string, string | undefined>,
) {
return this.alerts.receive({ project, token, body, headers });
}
}
function withPath(projectId: string, integration: AlertIntegration) {
const { token, ...rest } = integration;
return {
...rest,
webhookPath: token ? alertWebhookPath(projectId, token) : null,
};
}

View File

@@ -6,6 +6,7 @@ import {
Param,
Patch,
Post,
Put,
} from '@nestjs/common';
import { ApiOkResponse, ApiOperation, ApiTags } from '@nestjs/swagger';
import { User } from '@task-stream/api/identity/domain';
@@ -54,6 +55,29 @@ export class CommentsController {
return this.comments.react(me.id, commentId, body.emoji, body.on);
}
@Put(':commentId/decision')
@ApiOperation({
summary: 'Mark a comment as a decision of its ticket',
description:
'Whoever can change the ticket. Decisions are shown above the ticket and searchable (GET …/decisions).',
})
@ApiOkResponse({ type: CommandResultDto })
@ApiCommandErrors()
markDecision(@CurrentUser() me: User, @Param('commentId') commentId: string) {
return this.comments.markDecision(me.id, commentId, true);
}
@Delete(':commentId/decision')
@ApiOperation({ summary: 'It is no longer a decision' })
@ApiOkResponse({ type: CommandResultDto })
@ApiCommandErrors()
unmarkDecision(
@CurrentUser() me: User,
@Param('commentId') commentId: string,
) {
return this.comments.markDecision(me.id, commentId, false);
}
@Delete(':commentId')
@ApiOperation({ summary: 'Delete a comment (author or project admin)' })
@ApiOkResponse({ type: CommandResultDto })

View File

@@ -501,6 +501,22 @@ export class PageQueryDto {
offset = 0;
}
export class DecisionsQueryDto {
@ApiPropertyOptional({ description: 'Words in the decision' })
@IsOptional()
@IsString()
@MaxLength(200)
search?: string;
@ApiPropertyOptional({ type: Number, default: 20, minimum: 1, maximum: 100 })
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
@Max(100)
limit = 20;
}
// ---- Issues -----------------------------------------------------------------
export class MyIssuesQueryDto extends PageQueryDto {
@@ -1522,6 +1538,24 @@ export class PushEndpointDto {
endpoint!: string;
}
// ---- Alerts → bugs ----------------------------------------------------------
export class AlertSettingsDto {
@ApiPropertyOptional({
type: String,
nullable: true,
description: "Ticket type to open; null: the project's first bug type",
})
@IsOptional()
@IsString()
typeId?: string | null;
@ApiPropertyOptional({ enum: IssuePriority, enumName: 'IssuePriority' })
@IsOptional()
@IsEnum(IssuePriority)
priority?: IssuePriority;
}
// ---- Git --------------------------------------------------------------------
export class AddGitRepositoryDto {

View File

@@ -625,12 +625,30 @@ export class CommentDto {
description: 'In the order they are offered; only the ones somebody chose',
})
reactions!: CommentReactionDto[];
@ApiProperty({
description: 'Marked as a decision of the ticket (shown above it)',
})
decision!: boolean;
@ApiProperty({
type: UserRefDto,
nullable: true,
description: 'Who marked it as a decision',
})
decidedBy!: UserRefDto | null;
@ApiProperty({ type: String, nullable: true, format: 'date-time' })
decidedAt!: string | null;
@ApiProperty({ format: 'date-time' }) createdAt!: string;
@ApiProperty({ type: String, nullable: true, format: 'date-time' })
editedAt!: string | null;
@ApiProperty() version!: number;
}
/** A comment marked as a decision, with its ticket. */
export class DecisionDto {
@ApiProperty({ type: CommentDto }) comment!: CommentDto;
@ApiProperty({ type: IssueParentDto }) issue!: IssueParentDto;
}
export class CommentPageDto {
@ApiProperty({ type: [CommentDto] }) items!: CommentDto[];
@ApiProperty() total!: number;
@@ -1348,3 +1366,36 @@ export class SharedDashboardDto {
@ApiProperty({ type: [ActivityDayDto], nullable: true })
activity!: ActivityDayDto[] | null;
}
// ---- Alerts → bugs ----------------------------------------------------------
export class AlertIntegrationDto {
@ApiProperty() enabled!: boolean;
@ApiProperty({
type: String,
nullable: true,
description:
'Path of the URL on this API, secret token included; only owners and admins see it',
example: '/api/webhooks/alerts/0b4c…/9f2e…',
})
webhookPath!: string | null;
@ApiProperty({
type: String,
nullable: true,
description: "Ticket type it opens; null: the project's first bug type",
})
typeId!: string | null;
@ApiProperty({ enum: IssuePriority, enumName: 'IssuePriority' })
priority!: IssuePriority;
}
export class AlertOutcomeDto {
@ApiProperty({
enum: ['created', 'repeated', 'resolved', 'ignored'],
description:
'created: a new bug; repeated: it went to the open bug; resolved: said on it; ignored: not an alert, or over with nothing open',
})
action!: string;
@ApiProperty({ type: String, nullable: true, example: 'WEB-42' })
issueKey!: string | null;
}

View File

@@ -32,6 +32,7 @@ import {
import {
CommentsService,
IssuesService,
PinsService,
PresenceService,
ProjectsService,
} from '@task-stream/api/tracker/application';
@@ -44,6 +45,7 @@ import {
ChecklistItemBodyDto,
AddCommentDto,
CreateIssueDto,
DecisionsQueryDto,
IssueSearchQueryDto,
PresenceBodyDto,
LinkIssueDto,
@@ -60,7 +62,9 @@ import {
ActivityDto,
BulkResultDto,
CommandResultDto,
CommentDto,
CommentPageDto,
DecisionDto,
EpicDto,
IssueDto,
IssuePageDto,
@@ -94,6 +98,7 @@ export class IssuesController {
private readonly refs: Refs,
private readonly presence: PresenceService,
private readonly projects: ProjectsService,
private readonly pins: PinsService,
) {}
@Get('me/home')
@@ -163,6 +168,41 @@ export class IssuesController {
await this.presence.leave(me.id, await this.refs.issueId(me.id, issue));
}
@Get('me/pins')
@ApiOperation({
summary: 'My pinned tickets, most recently pinned first',
description:
'Across my projects; tickets in the trash or out of my reach are left out.',
})
@ApiOkResponse({ type: [IssueDto] })
@ApiReadErrors()
async myPins(@CurrentUser() me: User) {
return this.refs.issueDtos(await this.pins.mine(me.id), viewerOf(me));
}
@Put('issues/:issue/pin')
@HttpCode(204)
@ISSUE
@ApiOperation({
summary: 'Pin a ticket: keep it at hand on my home page and in search',
description: 'Private to me; at most 20. Pinning it again changes nothing.',
})
@ApiNoContentResponse()
@ApiCommandErrors()
async pin(@CurrentUser() me: User, @Param('issue') issue: string) {
await this.pins.pin(me.id, await this.refs.issueId(me.id, issue), true);
}
@Delete('issues/:issue/pin')
@HttpCode(204)
@ISSUE
@ApiOperation({ summary: 'Unpin a ticket' })
@ApiNoContentResponse()
@ApiCommandErrors()
async unpin(@CurrentUser() me: User, @Param('issue') issue: string) {
await this.pins.pin(me.id, await this.refs.issueId(me.id, issue), false);
}
@Put('issues/:issue/watch')
@ISSUE
@ApiOperation({
@@ -670,6 +710,57 @@ export class IssuesController {
);
}
@Get('issues/:issue/decisions')
@ApiOptionalPireonAuth()
@ISSUE
@ApiOperation({
summary: 'Comments of the ticket marked as decisions (oldest first)',
})
@ApiOkResponse({ type: [CommentDto] })
@ApiReadErrors()
async issueDecisions(
@CurrentUser() me: User | undefined,
@Param('issue') issue: string,
) {
return this.comments.decisions(
viewerOf(me),
await this.refs.issueId(viewerOf(me), issue),
);
}
@Get('projects/:project/decisions')
@ApiOptionalPireonAuth()
@PROJECT
@ApiOperation({
summary: "Search a project's decisions (most recent first)",
})
@ApiOkResponse({ type: [DecisionDto] })
@ApiReadErrors()
async projectDecisions(
@CurrentUser() me: User | undefined,
@Param('project') project: string,
@Query() query: DecisionsQueryDto,
) {
return this.comments.searchDecisions(viewerOf(me), {
projectId: await this.refs.projectId(viewerOf(me), project),
search: query.search,
limit: query.limit,
});
}
@Get('me/decisions')
@ApiOperation({
summary: 'Search the decisions of all my projects (most recent first)',
})
@ApiOkResponse({ type: [DecisionDto] })
@ApiReadErrors()
myDecisions(@CurrentUser() me: User, @Query() query: DecisionsQueryDto) {
return this.comments.searchDecisions(me.id, {
search: query.search,
limit: query.limit,
});
}
@Post('issues/:issue/comments')
@ISSUE
@ApiOperation({

View File

@@ -26,7 +26,9 @@ import { SavedFilterResolver } from './graphql/saved-filter.resolver';
import { SprintResolver } from './graphql/sprint.resolver';
import { SprintsController } from './http/sprints.controller';
import { GitController } from './http/git.controller';
import { AlertsController } from './http/alerts.controller';
import { GitResolver } from './graphql/git.resolver';
import { AlertResolver } from './graphql/alert.resolver';
import { WebhookResolver } from './graphql/webhook.resolver';
import { SharingResolver } from './graphql/sharing.resolver';
import { WebhooksController } from './http/webhooks.controller';
@@ -66,6 +68,7 @@ import { AutomationResolver } from './graphql/automation.resolver';
SavedFiltersController,
SprintsController,
GitController,
AlertsController,
WebhooksController,
SharingController,
TransferController,
@@ -86,6 +89,7 @@ import { AutomationResolver } from './graphql/automation.resolver';
SavedFilterResolver,
SprintResolver,
GitResolver,
AlertResolver,
WebhookResolver,
SharingResolver,
AutomationResolver,

View File

@@ -1,6 +1,7 @@
export * from './lib/pi-ui.providers';
export * from './lib/appearance';
export * from './lib/toast.service';
export * from './lib/undo-toast';
export * from './lib/app-shell';
export * from './lib/icons';
export * from './lib/drawer.service';

View File

@@ -112,6 +112,7 @@ export const APP_ICONS: Record<string, string> = {
task: '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><rect x="4" y="4" width="16" height="16" rx="3"/><path d="m8.5 12 2.5 2.5 4.5-5"/></svg>',
palette:
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M12 3a9 9 0 0 0 0 18c1.1 0 1.5-.8 1.5-1.5 0-1.2-1-1.5-1-2.5s.7-1.5 1.7-1.5H17a4 4 0 0 0 4-4c0-4.7-4-8.5-9-8.5Z"/><circle cx="7.5" cy="11" r="1"/><circle cx="10" cy="7" r="1"/><circle cx="15" cy="7.5" r="1"/></svg>',
pin: '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M9 4h6M10 4v5l-3 4h10l-3-4V4M12 13v7"/></svg>',
eye: '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M2 12s3.5-7 10-7 10 7 10 7-3.5 7-10 7S2 12 2 12Z"/><circle cx="12" cy="12" r="3"/></svg>',
'eye-off':
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M3 3l18 18M10.6 5.1A10.4 10.4 0 0 1 12 5c6.5 0 10 7 10 7a17 17 0 0 1-3.2 4.1M6.6 6.6A17 17 0 0 0 2 12s3.5 7 10 7a10 10 0 0 0 5.4-1.6M9.9 9.9a3 3 0 0 0 4.2 4.2"/></svg>',

View File

@@ -1,6 +1,7 @@
import { inject, Injectable } from '@angular/core';
import { MatSnackBar } from '@angular/material/snack-bar';
import { PiSnackBarComponent } from '@pi/ui';
import { UndoToastComponent } from './undo-toast';
type ToastType = 'success' | 'error' | 'info';
@@ -21,6 +22,23 @@ export class ToastService {
this.open(message, 'info', 'info', 3000);
}
/**
* Done, with «Deshacer» for a few seconds (also Ctrl/⌘ Z): `undo` runs if
* the person asks for it before the toast goes.
*/
undoable(message: string, undo: () => void): void {
this.snackBar
.openFromComponent(UndoToastComponent, {
data: { message },
duration: 7000,
horizontalPosition: 'end',
verticalPosition: 'bottom',
panelClass: 'ts-undo-toast',
})
.onAction()
.subscribe(() => undo());
}
private open(
message: string,
type: ToastType,

View File

@@ -0,0 +1,120 @@
import {
ChangeDetectionStrategy,
Component,
HostListener,
inject,
} from '@angular/core';
import {
MAT_SNACK_BAR_DATA,
MatSnackBarRef,
} from '@angular/material/snack-bar';
/** What an undoable toast says. */
export interface UndoToastData {
message: string;
}
/**
* A toast with «Deshacer» (or Ctrl/⌘ Z while it shows). `pi-snack-bar`
* cannot tell its button from the timeout, so this one is the app's own; it
* closes with an action when undone.
*/
@Component({
selector: 'ts-undo-toast',
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<span class="ts-undo__message">{{ data.message }}</span>
<button type="button" class="ts-undo__button" (click)="undo()">
Deshacer
</button>
<button
type="button"
class="ts-undo__close"
aria-label="Cerrar"
(click)="ref.dismiss()"
>
×
</button>
`,
styles: `
/* A solid surface: it must stand out over the board in both themes. */
:host {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.625rem 0.625rem 0.625rem 1.25rem;
border: 1px solid var(--ts-line-strong, rgb(0 0 0 / 15%));
border-radius: var(
--pi-theme-notification-radius,
var(--pi-border-radius-large, 0.75rem)
);
background-color: var(--ts-surface, #fff);
box-shadow: 0 14px 32px -14px rgb(0 0 0 / 40%);
color: var(--ts-ink, inherit);
font-weight: 500;
}
.ts-undo__button {
color: var(--ts-brand, var(--pi-color-primary));
}
.ts-undo__message {
flex: 1;
min-width: 0;
}
/* Plain buttons, not @pi/ui's sized ones. */
button {
min-width: 0 !important;
height: auto !important;
padding: 0.25rem 0.5rem !important;
border: 0;
border-radius: 0.375rem;
background: transparent;
color: inherit;
font: inherit;
cursor: pointer;
&:hover,
&:focus-visible {
background: color-mix(in srgb, currentColor 12%, transparent);
}
}
.ts-undo__button {
font-weight: 700;
text-decoration: underline;
text-underline-offset: 3px;
}
.ts-undo__close {
font-size: 1.125rem;
line-height: 1;
}
`,
})
export class UndoToastComponent {
protected readonly data = inject<UndoToastData>(MAT_SNACK_BAR_DATA);
protected readonly ref = inject(MatSnackBarRef);
protected undo(): void {
this.ref.dismissWithAction();
}
@HostListener('document:keydown', ['$event'])
protected onKey(event: KeyboardEvent): void {
if (
(event.ctrlKey || event.metaKey) &&
!event.shiftKey &&
event.key.toLowerCase() === 'z' &&
!isTyping(event.target)
) {
event.preventDefault();
this.undo();
}
}
}
/** Ctrl Z in a field undoes the typing, not the last action. */
function isTyping(target: EventTarget | null): boolean {
const element = target as HTMLElement | null;
return (
!!element &&
(element.isContentEditable ||
['INPUT', 'TEXTAREA', 'SELECT'].includes(element.tagName))
);
}

View File

@@ -6,9 +6,16 @@ import {
CommandResult,
CreateIssueInput,
IssueChanged,
IssuePriority,
SavedFilter,
} from '@task-stream/web/tracker/domain';
/** What a card can change without opening the ticket. */
export type CardChange =
| { priority: IssuePriority }
| { storyPoints: number | null }
| { assigneeId: string | null };
export const boardPageEvents = eventGroup({
source: 'Board Page',
events: {
@@ -19,8 +26,19 @@ export const boardPageEvents = eventGroup({
issueId: string;
to: string;
reason?: string | null;
/** Where it was: «Deshacer» takes it back. */
from?: string;
/** It is the «Deshacer» of a move: no undo for it. */
undoing?: boolean;
}>(),
issueCreateRequested: type<CreateIssueInput>(),
/** From the card: priority, estimate or assignee (the live update repaints it). */
issueEditRequested: type<{
issueId: string;
change: CardChange;
/** It is the «Deshacer» of a change: no undo for it. */
undoing?: boolean;
}>(),
/** Save the current filter under a name. */
viewSaveRequested: type<{ name: string; shared: boolean }>(),
viewDeleteRequested: type<{ id: string }>(),
@@ -33,6 +51,7 @@ export const boardApiEvents = eventGroup({
loaded: type<BoardColumn[]>(),
loadFailed: type<string>(),
issueMoveFailed: type<string>(),
issueEditFailed: type<string>(),
issueCreated: type<CommandResult>(),
issueCreateFailed: type<string>(),
viewsLoaded: type<SavedFilter[]>(),

View File

@@ -15,8 +15,13 @@ export const issuePageEvents = eventGroup({
source: 'Issue Page',
events: {
opened: type<{ idOrKey: string }>(),
statusChangeRequested: type<ChangeStatusInput>(),
assignRequested: type<{ issueId: string; assigneeId: string | null }>(),
/** `undoing`: it is the «Deshacer» of a change, which offers none. */
statusChangeRequested: type<ChangeStatusInput & { undoing?: boolean }>(),
assignRequested: type<{
issueId: string;
assigneeId: string | null;
undoing?: boolean;
}>(),
updateRequested: type<UpdateIssueInput>(),
linkRequested: type<LinkIssuesInput>(),
/** Relate by the other ticket's key (e.g. BE-3), resolved to its id first. */
@@ -43,7 +48,12 @@ export const issuePageEvents = eventGroup({
replyTo?: string | null;
}>(),
watchRequested: type<{ issueId: string; watching: boolean }>(),
trashRequested: type<{ issueId: string; trashed: boolean }>(),
pinRequested: type<{ issueId: string; pinned: boolean }>(),
trashRequested: type<{
issueId: string;
trashed: boolean;
undoing?: boolean;
}>(),
/** Place the ticket under another one by its key (e.g. WEB-3); null: out. */
parentRequested: type<{ issueId: string; parentKey: string | null }>(),
subtaskCreateRequested: type<{
@@ -70,6 +80,7 @@ export const issuePageEvents = eventGroup({
}>(),
commentEditRequested: type<{ commentId: string; body: string }>(),
reactRequested: type<{ commentId: string; emoji: string; on: boolean }>(),
decisionRequested: type<{ commentId: string; on: boolean }>(),
voteRequested: type<{ issueId: string; on: boolean }>(),
/** An image pasted into a text was uploaded (it joins the ticket's files). */
imagePasted: type<{ issueId: string }>(),
@@ -88,6 +99,8 @@ export const issueApiEvents = eventGroup({
commandSucceeded: type<string>(),
/** A checklist step was saved (no toast for something so small). */
checklistSaved: type<void>(),
/** Changed, and «Deshacer» offered instead of a plain toast. */
undoOffered: type<void>(),
commandFailed: type<string>(),
},
});

View File

@@ -6,7 +6,7 @@ import {
CreateIssueInput,
sameCriteria,
} from '@task-stream/web/tracker/domain';
import { boardPageEvents } from '../events/board.events';
import { boardPageEvents, CardChange } from '../events/board.events';
import { BoardStore } from '../stores/board.store';
/** Kanban board of a project, live. */
@@ -43,7 +43,15 @@ export class BoardFacade {
/** Optimistic: the card moves at once and comes back if the API refuses. */
moveIssue(issueId: string, to: string, reason?: string | null): void {
this.dispatch.issueMoved({ issueId, to, reason });
const from = this.store
.columns()
.find((column) => column.issues.some((i) => i.id === issueId))?.status;
this.dispatch.issueMoved({ issueId, to, reason, from });
}
/** A change from the card itself, without opening the ticket. */
editIssue(issueId: string, change: CardChange): void {
this.dispatch.issueEditRequested({ issueId, change });
}
createIssue(input: CreateIssueInput): void {
@@ -65,3 +73,6 @@ export class BoardFacade {
export function provideBoardFacade(): Provider[] {
return [BoardStore, BoardFacade];
}
/** What a card can change by itself (see BoardFacade.editIssue). */
export type { CardChange } from '../events/board.events';

View File

@@ -30,7 +30,7 @@ import { ProjectFacade, provideProjectFacade } from './project.facade';
const ok = { id: 'x', key: null, aggregateVersion: 1 };
const project = { id: 'p1', key: 'WEB', members: [] } as unknown as Project;
const issue = {
const issueFake = {
id: 'i1',
key: 'WEB-1',
projectId: 'p1',
@@ -54,7 +54,7 @@ function gateways() {
},
issues: {
board: vi.fn(() => of([])),
issue: vi.fn(() => of(issue)),
issue: vi.fn(() => of(issueFake)),
changeStatus: vi.fn(() => of(ok)),
watch: vi.fn(() => of(ok)),
children: vi.fn(() => of([])),
@@ -130,7 +130,7 @@ function setup() {
{ provide: SprintsGateway, useValue: fakes.sprints },
{
provide: Notifications,
useValue: { success: vi.fn(), error: vi.fn() },
useValue: { success: vi.fn(), error: vi.fn(), undoable: vi.fn() },
},
{ provide: AuthFacade, useValue: { user: signal({ id: 'me' }) } },
],
@@ -348,6 +348,25 @@ describe('IssueFacade', () => {
expect(fakes.issues.watch).toHaveBeenCalledWith('i1', true);
});
it('offers to undo a move, and the undo goes back without offering another', () => {
const { issue, fakes, notifications } = setup();
fakes.issues.issue.mockImplementation((() =>
of({ ...issueFake, status: 'TODO' })) as never);
issue.open('WEB-1');
issue.changeStatus('IN_PROGRESS');
const undoable = vi.mocked(notifications.undoable);
expect(undoable).toHaveBeenCalledTimes(1);
expect(notifications.success).not.toHaveBeenCalled();
undoable.mock.calls[0][1]();
expect(fakes.issues.changeStatus).toHaveBeenLastCalledWith({
issueId: 'i1',
status: 'TODO',
});
expect(undoable).toHaveBeenCalledTimes(1);
expect(notifications.success).toHaveBeenCalledWith('Deshecho');
});
it('breaks the ticket down: subtasks, parent and checklist', () => {
const { issue, fakes } = setup();
// The parent is looked up by its key first.
@@ -503,7 +522,7 @@ describe('InboxFacade', () => {
},
{
provide: Notifications,
useValue: { success: vi.fn(), error: vi.fn() },
useValue: { success: vi.fn(), error: vi.fn(), undoable: vi.fn() },
},
{
provide: AuthFacade,

View File

@@ -120,6 +120,13 @@ export class IssueFacade {
);
}
/** Pin the ticket for me (or unpin it): at hand on my home page and in search. */
setPinned(pinned: boolean): void {
this.withIssue((issueId) =>
this.dispatch.pinRequested({ issueId, pinned }),
);
}
/** Places the open ticket under another one by its key; null: back to the top. */
/**
* Tickets to link to or hang the open one from: the most recently updated
@@ -224,6 +231,11 @@ export class IssueFacade {
this.dispatch.reactRequested({ commentId, emoji, on });
}
/** Marks a comment as a decision of the ticket (`on`) or unmarks it. */
markDecision(commentId: string, on: boolean): void {
this.dispatch.decisionRequested({ commentId, on });
}
/** A copy of what the open ticket says; answers its key. */
duplicate(): Observable<string | null> {
const issueId = this.store.issue()?.id ?? '';

View File

@@ -2,6 +2,8 @@
export abstract class Notifications {
abstract success(message: string): void;
abstract error(message: string): void;
/** Done, offering to undo it for a few seconds: `undo` runs if asked. */
abstract undoable(message: string, undo: () => void): void;
}
/** Message of a failed GraphQL/HTTP call, for the user. */

View File

@@ -3,6 +3,7 @@ import { mapResponse } from '@ngrx/operators';
import { signalStore, withComputed, withState } from '@ngrx/signals';
import {
Events,
injectDispatch,
on,
withEventHandlers,
withReducer,
@@ -33,7 +34,7 @@ import {
boardRealtimeEvents,
} from '../events/board.events';
import { describeError, Notifications } from '../notifications';
import { applyIssueChange, moveIssue } from './board.reducers';
import { applyIssueChange, findIssue, moveIssue } from './board.reducers';
interface BoardState {
filter: BoardFilter | null;
@@ -92,6 +93,7 @@ export const BoardStore = signalStore(
realtime = inject(TrackerRealtimeGateway),
notifications = inject(Notifications),
savedFilters = inject(SavedFiltersGateway),
dispatch = injectDispatch(boardPageEvents),
) => {
const loadViews = (projectId: string) =>
savedFilters.list(projectId).pipe(
@@ -170,22 +172,72 @@ export const BoardStore = signalStore(
}),
),
move$: events.on(boardPageEvents.issueMoved).pipe(
mergeMap(({ payload }) =>
issues
mergeMap(({ payload }) => {
const key = findIssue(store.columns(), payload.issueId)?.key ?? '';
return issues
.changeStatus({
issueId: payload.issueId,
status: payload.to,
reason: payload.reason,
})
.pipe(
tap(() => {
const { from } = payload;
if (payload.undoing) notifications.success('Deshecho');
else if (from && from !== payload.to)
notifications.undoable(`${key} movido`, () =>
dispatch.issueMoved({
issueId: payload.issueId,
to: from,
undoing: true,
}),
);
}),
mapResponse({
next: () => null,
error: (error) =>
boardApiEvents.issueMoveFailed(describeError(error)),
}),
),
),
);
}),
),
edit$: events.on(boardPageEvents.issueEditRequested).pipe(
mergeMap(({ payload: { issueId, change, undoing } }) => {
// Not optimistic: the card still shows who had it.
const card = findIssue(store.columns(), issueId);
const before = card?.assignee?.id ?? null;
return (
'assigneeId' in change
? issues.assign(issueId, change.assigneeId)
: issues.update({ issueId, ...change })
).pipe(
tap(() => {
if (!('assigneeId' in change) || !card) return;
if (undoing) notifications.success('Deshecho');
else if (before !== change.assigneeId)
notifications.undoable(
change.assigneeId
? `${card.key} reasignado`
: `${card.key} sin asignar`,
() =>
dispatch.issueEditRequested({
issueId,
change: { assigneeId: before },
undoing: true,
}),
);
}),
mapResponse({
next: () => null,
error: (error) =>
boardApiEvents.issueEditFailed(describeError(error)),
}),
);
}),
),
editFailed$: events
.on(boardApiEvents.issueEditFailed)
.pipe(tap(({ payload }) => notifications.error(payload))),
/** The API refused the move: tell the user and put the card back. */
rollback$: events.on(boardApiEvents.issueMoveFailed).pipe(
tap(({ payload }) => notifications.error(payload)),

View File

@@ -3,6 +3,7 @@ import { mapResponse } from '@ngrx/operators';
import { signalStore, withComputed, withState } from '@ngrx/signals';
import {
Events,
injectDispatch,
on,
withEventHandlers,
withReducer,
@@ -36,6 +37,7 @@ import {
tap,
throwError,
timer,
Observable,
} from 'rxjs';
import {
issueApiEvents,
@@ -124,7 +126,28 @@ export const IssueStore = signalStore(
notifications = inject(Notifications),
attachments = inject(AttachmentsGateway),
sprints = inject(SprintsGateway),
dispatch = injectDispatch(issuePageEvents),
) => {
/**
* Runs a change that can be taken back: on success, «Deshacer» for a
* few seconds runs `undo`; the undo itself only says it is done.
*/
const runUndoable = (
command$: Observable<unknown>,
message: string,
undo: (() => void) | null,
) =>
command$.pipe(
mapResponse({
next: () => {
if (!undo) return issueApiEvents.commandSucceeded('Deshecho');
notifications.undoable(message, undo);
return issueApiEvents.undoOffered();
},
error: (error) =>
issueApiEvents.commandFailed(describeError(error)),
}),
);
/** One file after another: the ticket's version moves with each. */
const uploadAll = (
issueId: string,
@@ -247,6 +270,7 @@ export const IssueStore = signalStore(
events.on(
issueApiEvents.commandSucceeded,
issueApiEvents.checklistSaved,
issueApiEvents.undoOffered,
issuePageEvents.imagePasted,
),
// Back after a cut: comments or changes may have been missed.
@@ -285,30 +309,41 @@ export const IssueStore = signalStore(
map((change) => issueRealtimeEvents.commentChanged(change)),
),
commands$: merge(
events
.on(issuePageEvents.statusChangeRequested)
.pipe(
mergeMap(({ payload }) =>
runCommand(
issues.changeStatus(payload),
'Estado actualizado',
done,
failed,
),
),
),
events
.on(issuePageEvents.assignRequested)
.pipe(
mergeMap(({ payload }) =>
runCommand(
issues.assign(payload.issueId, payload.assigneeId),
payload.assigneeId ? 'Asignado' : 'Sin asignar',
done,
failed,
),
),
),
events.on(issuePageEvents.statusChangeRequested).pipe(
mergeMap(({ payload: { undoing, ...input } }) => {
// The page waits for the API: the store still has the old one.
const before = store.issue()?.status;
return runUndoable(
issues.changeStatus(input),
'Estado actualizado',
undoing || !before || before === input.status
? null
: () =>
dispatch.statusChangeRequested({
issueId: input.issueId,
status: before,
undoing: true,
}),
);
}),
),
events.on(issuePageEvents.assignRequested).pipe(
mergeMap(({ payload }) => {
const before = store.issue()?.assignee?.id ?? null;
return runUndoable(
issues.assign(payload.issueId, payload.assigneeId),
payload.assigneeId ? 'Asignado' : 'Sin asignar',
payload.undoing || before === payload.assigneeId
? null
: () =>
dispatch.assignRequested({
issueId: payload.issueId,
assigneeId: before,
undoing: true,
}),
);
}),
),
events
.on(issuePageEvents.updateRequested)
.pipe(
@@ -321,17 +356,38 @@ export const IssueStore = signalStore(
),
),
),
events.on(issuePageEvents.trashRequested).pipe(
mergeMap(({ payload }) =>
payload.trashed
? runUndoable(
issues.trash(payload.issueId),
'Ticket en la papelera',
payload.undoing
? null
: () =>
dispatch.trashRequested({
issueId: payload.issueId,
trashed: false,
undoing: true,
}),
)
: runCommand(
issues.restore(payload.issueId),
payload.undoing ? 'Deshecho' : 'Ticket recuperado',
done,
failed,
),
),
),
events
.on(issuePageEvents.trashRequested)
.on(issuePageEvents.pinRequested)
.pipe(
mergeMap(({ payload }) =>
runCommand(
payload.trashed
? issues.trash(payload.issueId)
: issues.restore(payload.issueId),
payload.trashed
? 'Ticket en la papelera'
: 'Ticket recuperado',
issues.pin(payload.issueId, payload.pinned),
payload.pinned
? 'Fijado: lo tienes a mano en el inicio y al buscar'
: 'Ya no está fijado',
done,
failed,
),
@@ -409,6 +465,20 @@ export const IssueStore = signalStore(
),
),
),
events
.on(issuePageEvents.decisionRequested)
.pipe(
mergeMap(({ payload }) =>
runCommand(
comments.markDecision(payload.commentId, payload.on),
payload.on
? 'Marcado como decisión: sale arriba del ticket'
: 'Ya no es una decisión',
done,
failed,
),
),
),
events.on(issuePageEvents.reactRequested).pipe(
mergeMap(({ payload }) =>
comments.react(payload.commentId, payload.emoji, payload.on).pipe(

View File

@@ -190,6 +190,10 @@ export interface Issue extends IssueSummary {
attachments: Attachment[];
/** People following the ticket. */
watchers: UserRef[];
/** Whether I keep it pinned (home page and search). */
pinned: boolean;
/** Its comments marked as decisions, oldest first. */
decisions: Comment[];
checklist: ChecklistItem[];
/** Commits, branches and pull requests that mention it. */
devLinks: DevLink[];
@@ -312,6 +316,16 @@ export interface GitIntegration {
repositories: GitRepository[];
}
/** Alerts → bugs: the secret URL monitoring tools call. */
export interface AlertIntegration {
enabled: boolean;
/** With its secret token; only for owners and admins. */
webhookPath: string | null;
/** Ticket type it opens; null: the project's first bug type. */
typeId: string | null;
priority: IssuePriority;
}
export interface Comment {
id: string;
issueId: string;
@@ -324,10 +338,20 @@ export interface Comment {
deleted: boolean;
/** 👍… and who chose each (only the ones somebody chose). */
reactions: { emoji: string; users: UserRef[] }[];
/** Marked as a decision of the ticket: shown above it and searchable. */
decision: boolean;
/** Who marked it as a decision (null when it is not one). */
decidedBy: UserRef | null;
createdAt: string;
editedAt: string | null;
}
/** A decision found by its text, with its ticket. */
export interface Decision {
comment: Comment;
issue: { id: string; key: string; title: string };
}
export interface SprintProgress {
total: number;
/** Done or cancelled. */

View File

@@ -1,3 +1,4 @@
import { IssuePriority } from '../models/enums';
import { Home } from '../models/home';
import { PortfolioProject, ProjectInsights } from '../models/insights';
import {
@@ -15,7 +16,9 @@ import {
CalendarFeed,
PushDeviceSubscription,
CommandResult,
AlertIntegration,
Comment,
Decision,
Epic,
GitIntegration,
GitRepository,
@@ -140,6 +143,18 @@ export abstract class IssuesGateway {
): Observable<CommandResult>;
/** The signed-in person follows (or stops following) the ticket. */
abstract watch(issueId: string, watching: boolean): Observable<CommandResult>;
/** My pinned tickets, most recently pinned first. */
abstract pins(): Observable<IssueSummary[]>;
/** Pins the ticket for me (or unpins it): private, at most 20. */
abstract pin(issueId: string, pinned: boolean): Observable<void>;
/**
* Decisions (comments marked as such) of all my projects by their text,
* most recent first: here and not in CommentsGateway, for the palette.
*/
abstract searchDecisions(
search: string,
limit: number,
): Observable<Decision[]>;
/** Tickets hanging from `issueId`. */
abstract children(
projectId: string,
@@ -234,6 +249,19 @@ export abstract class WebhooksGateway {
}
/** The project's Git webhook (owners and admins configure it). */
/** Alerts → bugs (owners and admins). */
export abstract class AlertsGateway {
abstract integration(projectId: string): Observable<AlertIntegration>;
/** Turns it on, or changes what it opens; the URL stays. */
abstract save(
projectId: string,
settings: { typeId: string | null; priority: IssuePriority },
): Observable<AlertIntegration>;
/** A new URL; the old one stops working. */
abstract rotateToken(projectId: string): Observable<AlertIntegration>;
abstract disable(projectId: string): Observable<void>;
}
export abstract class GitGateway {
abstract integration(projectId: string): Observable<GitIntegration>;
/** A repository with its own secret (the first turns the integration on). */
@@ -285,6 +313,11 @@ export abstract class CommentsGateway {
on: boolean,
): Observable<CommandResult>;
abstract remove(commentId: string): Observable<CommandResult>;
/** Marks it as a decision of its ticket (`on`) or unmarks it. */
abstract markDecision(
commentId: string,
on: boolean,
): Observable<CommandResult>;
}
/** A ticket's files. Uploads and downloads go through the REST API. */

View File

@@ -0,0 +1,52 @@
import { inject, Injectable } from '@angular/core';
import {
AlertIntegration,
AlertsGateway,
IssuePriority,
} from '@task-stream/web/tracker/domain';
import { map, Observable } from 'rxjs';
import {
AlertIntegrationDocument,
DisableAlertIntegrationDocument,
RotateAlertTokenDocument,
SaveAlertIntegrationDocument,
} from '../graphql/generated';
import { GraphqlOperations } from './graphql-operations';
@Injectable()
export class GraphqlAlertsGateway extends AlertsGateway {
private readonly gql = inject(GraphqlOperations);
integration(projectId: string): Observable<AlertIntegration> {
return this.gql
.query(AlertIntegrationDocument, { projectId })
.pipe(map((d) => d.alertIntegration as AlertIntegration));
}
save(
projectId: string,
settings: { typeId: string | null; priority: IssuePriority },
): Observable<AlertIntegration> {
return this.gql
.mutate(SaveAlertIntegrationDocument, {
projectId,
input: {
typeId: settings.typeId,
priority: settings.priority as never,
},
})
.pipe(map((d) => d.saveAlertIntegration as AlertIntegration));
}
rotateToken(projectId: string): Observable<AlertIntegration> {
return this.gql
.mutate(RotateAlertTokenDocument, { projectId })
.pipe(map((d) => d.rotateAlertToken as AlertIntegration));
}
disable(projectId: string): Observable<void> {
return this.gql
.mutate(DisableAlertIntegrationDocument, { projectId })
.pipe(map(() => undefined));
}
}

View File

@@ -23,6 +23,7 @@ import {
EditCommentDocument,
IssueCommentsDocument,
ReactToCommentDocument,
MarkDecisionDocument,
PublishReleaseDocument,
ReleaseBurnupDocument,
ReleaseNotesDraftDocument,
@@ -75,6 +76,12 @@ export class GraphqlCommentsGateway extends CommentsGateway {
.mutate(DeleteCommentDocument, { commentId })
.pipe(map((d) => toCommandResult(d.deleteComment)));
}
markDecision(commentId: string, on: boolean): Observable<CommandResult> {
return this.gql
.mutate(MarkDecisionDocument, { commentId, on })
.pipe(map((d) => toCommandResult(d.markDecision)));
}
}
@Injectable()

View File

@@ -7,6 +7,7 @@ import {
ChangeStatusInput,
CommandResult,
CreateIssueInput,
Decision,
Epic,
Issue,
IssuePage,
@@ -43,9 +44,13 @@ import {
SetIssueParentDocument,
SetIssueReleaseDocument,
UnlinkIssuesDocument,
UnpinIssueDocument,
UnwatchIssueDocument,
VoteIssueDocument,
WatchIssueDocument,
MyPinsDocument,
SearchDecisionsDocument,
PinIssueDocument,
UpdateChecklistItemDocument,
UpdateIssueDocument,
} from '../graphql/generated';
@@ -54,6 +59,7 @@ import {
toCommandResult,
toIssue,
toIssueLink,
toComment,
toIssueSummary,
} from './mappers';
@@ -192,6 +198,31 @@ export class GraphqlIssuesGateway extends IssuesGateway {
.pipe(map((d) => toCommandResult(d.voteIssue)));
}
pins(): Observable<IssueSummary[]> {
return this.gql
.query(MyPinsDocument, {})
.pipe(map((d) => d.myPins.map(toIssueSummary)));
}
searchDecisions(search: string, limit: number): Observable<Decision[]> {
return this.gql
.query(SearchDecisionsDocument, { search: search.trim() || null, limit })
.pipe(
map((d) =>
d.decisions.map((found) => ({
comment: toComment(found.comment),
issue: found.issue,
})),
),
);
}
pin(issueId: string, pinned: boolean): Observable<void> {
const done$: Observable<unknown> = pinned
? this.gql.mutate(PinIssueDocument, { issueId })
: this.gql.mutate(UnpinIssueDocument, { issueId });
return done$.pipe(map(() => undefined));
}
watch(issueId: string, watching: boolean): Observable<CommandResult> {
return watching
? this.gql

View File

@@ -0,0 +1,28 @@
fragment AlertIntegrationFields on AlertIntegration {
enabled
webhookPath
typeId
priority
}
query AlertIntegration($projectId: ID!) {
alertIntegration(projectId: $projectId) {
...AlertIntegrationFields
}
}
mutation SaveAlertIntegration($projectId: ID!, $input: AlertSettingsInput!) {
saveAlertIntegration(projectId: $projectId, input: $input) {
...AlertIntegrationFields
}
}
mutation RotateAlertToken($projectId: ID!) {
rotateAlertToken(projectId: $projectId) {
...AlertIntegrationFields
}
}
mutation DisableAlertIntegration($projectId: ID!) {
disableAlertIntegration(projectId: $projectId)
}

View File

@@ -120,6 +120,10 @@ fragment IssueSummaryFields on Issue {
fragment IssueFields on Issue {
...IssueSummaryFields
pinned
decisions {
...CommentFields
}
description
figmaUrl
createdAt
@@ -177,6 +181,10 @@ fragment CommentFields on Comment {
...UserRefFields
}
}
decision
decidedBy {
...UserRefFields
}
createdAt
editedAt
author {

File diff suppressed because it is too large Load Diff

View File

@@ -95,6 +95,20 @@ mutation UnwatchIssue($issueId: ID!) {
}
}
query MyPins {
myPins {
...IssueSummaryFields
}
}
mutation PinIssue($issueId: ID!) {
pinIssue(issueId: $issueId)
}
mutation UnpinIssue($issueId: ID!) {
unpinIssue(issueId: $issueId)
}
mutation SetIssueRelease($issueId: ID!, $releaseId: ID) {
setIssueRelease(issueId: $issueId, releaseId: $releaseId) {
...CommandResultFields
@@ -279,6 +293,25 @@ mutation ReactToComment($commentId: ID!, $emoji: String!, $on: Boolean!) {
}
}
mutation MarkDecision($commentId: ID!, $on: Boolean!) {
markDecision(commentId: $commentId, on: $on) {
...CommandResultFields
}
}
query SearchDecisions($search: String, $limit: Int!) {
decisions(search: $search, limit: $limit) {
comment {
...CommentFields
}
issue {
id
key
title
}
}
}
mutation DuplicateIssue($issueId: ID!) {
duplicateIssue(issueId: $issueId) {
...CommandResultFields

View File

@@ -16,6 +16,7 @@ import {
ReleasesGateway,
SavedFiltersGateway,
SprintsGateway,
AlertsGateway,
GitGateway,
WebhooksGateway,
TransferGateway,
@@ -42,6 +43,7 @@ import { GraphqlRealtimeGateway } from './adapters/graphql-realtime.gateway';
import { GraphqlSavedFiltersGateway } from './adapters/graphql-saved-filters.gateway';
import { GraphqlSprintsGateway } from './adapters/graphql-sprints.gateway';
import { GraphqlGitGateway } from './adapters/graphql-git.gateway';
import { GraphqlAlertsGateway } from './adapters/graphql-alerts.gateway';
import { GraphqlWebhooksGateway } from './adapters/graphql-webhooks.gateway';
import { HttpTransferGateway } from './adapters/http-transfer.gateway';
import { GraphqlAutomationsGateway } from './adapters/graphql-automations.gateway';
@@ -78,6 +80,7 @@ export function provideTrackerPagesInfrastructure(): EnvironmentProviders {
{ provide: SavedFiltersGateway, useClass: GraphqlSavedFiltersGateway },
{ provide: SprintsGateway, useClass: GraphqlSprintsGateway },
{ provide: GitGateway, useClass: GraphqlGitGateway },
{ provide: AlertsGateway, useClass: GraphqlAlertsGateway },
{ provide: WebhooksGateway, useClass: GraphqlWebhooksGateway },
{ provide: TransferGateway, useClass: HttpTransferGateway },
{ provide: AutomationsGateway, useClass: GraphqlAutomationsGateway },

View File

@@ -312,6 +312,9 @@ const NOBODY = '__nobody__';
[cdkDragData]="issue"
[issue]="issue"
[workflow]="project.workflow()"
[editable]="project.canContribute()"
[members]="cardMembers()"
(edited)="board.editIssue(issue.id, $event)"
/>
} @empty {
<p class="ts-column__empty">Nada por aquí</p>
@@ -613,6 +616,11 @@ export class BoardPage {
...options(PRIORITY),
];
/** Who a card can be assigned to. */
protected readonly cardMembers = computed(() =>
this.project.members().map((m) => ({ id: m.user.id, name: m.user.name })),
);
/* Options of the new ticket form: the empty value means nobody / none. */
protected readonly assigneeOptions = computed<PiSelectFieldOption<string>[]>(
() => [

View File

@@ -24,6 +24,9 @@
.ts-issue__main {
display: grid;
// Without it the column takes the width of its widest field: on a phone,
// wider than the screen.
grid-template-columns: minmax(0, 1fr);
gap: 2rem;
min-width: 0;
}
@@ -328,6 +331,109 @@ a.ts-key {
}
}
/* A decision: marked on its comment, and listed above the ticket. */
.ts-comment.is-decision {
margin-inline: -0.75rem;
padding-inline: 0.75rem;
border-left: 3px solid var(--ts-c-green);
background: color-mix(in srgb, var(--ts-c-green) 6%, transparent);
}
.ts-comment.is-pointed {
animation: ts-pointed 2.4s ease-out;
}
@keyframes ts-pointed {
0%,
40% {
box-shadow: 0 0 0 3px color-mix(in srgb, var(--ts-brand) 45%, transparent);
}
100% {
box-shadow: 0 0 0 3px transparent;
}
}
.ts-comment__decision {
padding: 0.0625rem 0.5rem;
border-radius: 999px;
background: color-mix(in srgb, var(--ts-c-green) 16%, transparent);
color: var(--ts-c-green);
font-size: 0.6875rem;
font-weight: 700;
letter-spacing: 0.04em;
text-transform: uppercase;
}
/* A plain icon button, not @pi/ui's sized one. */
.ts-comment__flag {
display: inline-grid;
place-items: center;
width: 2rem !important;
min-width: 0 !important;
height: 2rem !important;
padding: 0 !important;
border: 0;
border-radius: 0.5rem;
background: transparent;
color: var(--ts-muted);
cursor: pointer;
&:hover,
&:focus-visible {
background: var(--ts-sunken);
color: var(--ts-ink);
}
&.is-on {
color: var(--ts-c-green);
}
}
.ts-decisions {
ol {
display: grid;
gap: 0.875rem;
margin: 0;
padding: 0;
list-style: none;
}
li {
padding: 0.75rem 1rem;
border-left: 3px solid var(--ts-c-green);
border-radius: 0 0.5rem 0.5rem 0;
background: color-mix(in srgb, var(--ts-c-green) 7%, var(--ts-surface));
}
.ts-section pi-icon {
color: var(--ts-c-green);
}
}
.ts-decisions__meta {
margin: 0.375rem 0 0;
color: var(--ts-muted);
font-size: 0.8125rem;
}
/* Looks like a link: not @pi/ui's sized button. */
.ts-decisions__go {
display: inline !important;
min-width: 0 !important;
height: auto !important;
padding: 0 !important;
border: 0;
background: none !important;
color: var(--ts-brand);
font: inherit;
cursor: pointer;
&:hover,
&:focus-visible {
text-decoration: underline;
}
}
.ts-comment__head {
display: flex;
align-items: center;

View File

@@ -205,6 +205,21 @@ const MAX_FILE_BYTES = 25 * 1024 * 1024;
(execute)="facade.setWatching(!facade.watching())"
/>
}
@if (project.myRole()) {
<pi-icon-button
icon="pin"
color="grey"
appearance="outline"
[text]="issue.pinned ? 'Fijado' : 'Fijar'"
[attr.aria-pressed]="issue.pinned"
[attr.title]="
issue.pinned
? 'Quitar de mis fijados'
: 'Tenerlo a mano en el inicio y al buscar'
"
(execute)="facade.setPinned(!issue.pinned)"
/>
}
@if (project.canContribute()) {
<pi-icon-button
icon="edit"
@@ -244,6 +259,47 @@ const MAX_FILE_BYTES = 25 * 1024 * 1024;
</div>
}
@if (issue.decisions.length) {
<article
class="ts-block ts-decisions"
aria-labelledby="decisions-title"
>
<h2 class="ts-section" id="decisions-title">
<pi-icon icon="flag" size="small" />
Decisiones
<span class="ts-mono">{{ issue.decisions.length }}</span>
</h2>
<ol>
@for (decision of issue.decisions; track decision.id) {
<li>
<ts-markdown
[text]="decision.body"
[me]="facade.me()"
[loader]="loadImage"
/>
<p class="ts-decisions__meta">
{{ decision.author.name }} ·
{{ ago(decision.createdAt) }}
@if (decision.decidedBy; as by) {
@if (by.id !== decision.author.id) {
· marcada por {{ by.name }}
}
}
·
<button
type="button"
class="ts-decisions__go"
(click)="goToComment(decision.id)"
>
Ver en la conversación
</button>
</p>
</li>
}
</ol>
</article>
}
<article class="ts-block">
<h2 class="ts-section">Descripción</h2>
@if (issue.description) {
@@ -600,7 +656,11 @@ const MAX_FILE_BYTES = 25 * 1024 * 1024;
<span class="ts-mono">{{ commentCount() }}</span>
</h2>
<ng-template #commentView let-comment>
<div class="ts-comment">
<div
class="ts-comment"
[id]="'comment-' + comment.id"
[class.is-decision]="comment.decision && !comment.deleted"
>
<span class="ts-comment__avatar" aria-hidden="true">{{
initialsOf(comment.author.name)
}}</span>
@@ -613,7 +673,34 @@ const MAX_FILE_BYTES = 25 * 1024 * 1024;
>{{ ago(comment.createdAt)
}}{{ comment.editedAt ? ' · editado' : '' }}</span
>
@if (comment.decision && !comment.deleted) {
<span class="ts-comment__decision">Decisión</span>
}
<span class="ts-spacer"></span>
@if (!comment.deleted && project.canContribute()) {
<!-- Native: pi-icon-button leaves its button unnamed. -->
<button
type="button"
class="ts-comment__flag"
[class.is-on]="comment.decision"
[attr.aria-pressed]="comment.decision"
[attr.aria-label]="
comment.decision
? 'Quitar de las decisiones'
: 'Marcar como decisión'
"
[attr.title]="
comment.decision
? 'Quitar de las decisiones'
: 'Marcar como decisión: sale arriba del ticket'
"
(click)="
facade.markDecision(comment.id, !comment.decision)
"
>
<pi-icon icon="flag" size="small" />
</button>
}
@if (
!comment.deleted && comment.author.id === facade.me()
) {
@@ -1013,6 +1100,8 @@ const MAX_FILE_BYTES = 25 * 1024 * 1024;
export class IssuePage {
/** Route param (`withComponentInputBinding`). */
readonly issueKey = input.required<string>();
/** `?comment=` (query param): the comment to bring into view. */
readonly comment = input<string>();
protected readonly facade = inject(IssueFacade);
protected readonly project = inject(ProjectFacade);
@@ -1319,12 +1408,29 @@ export class IssuePage {
});
});
});
effect(() => {
const id = this.comment();
// Once its comment has arrived (they load after the ticket).
if (id && this.facade.comments().some((c) => c.id === id))
untracked(() => setTimeout(() => this.goToComment(id)));
});
effect(() => {
const editing = this.editingInDrawer() || !!this.commentBody().trim();
untracked(() => this.presence.setEditing(editing));
});
}
/** Scrolls to a comment of the conversation and points at it. */
protected goToComment(id: string): void {
const element = document.getElementById(`comment-${id}`);
if (!element) return;
element.scrollIntoView({ behavior: 'smooth', block: 'center' });
element.classList.remove('is-pointed');
// Restart the animation when it is pointed at again.
void element.offsetWidth;
element.classList.add('is-pointed');
}
/** While a form is open in the drawer, I am editing. */
private trackEditing(ref: {
afterClosed(): { subscribe(f: () => void): unknown };
@@ -1493,13 +1599,9 @@ export class IssuePage {
);
}
/** No confirmation: the toast offers «Deshacer», and the trash keeps it 30 days. */
protected trash(): void {
if (
confirm(
'¿Mover el ticket a la papelera? Podrás recuperarlo durante 30 días.',
)
)
this.facade.setTrashed(true);
this.facade.setTrashed(true);
}
/** `e`: edit the ticket (not while typing, nor with a drawer open). */

View File

@@ -53,6 +53,7 @@ import { ResumeComponent } from '../ui/home/resume';
import { WeekAgendaComponent } from '../ui/home/week-agenda';
import { HomeKpisComponent } from '../ui/home/kpis';
import { MyViewsComponent } from '../ui/my-views';
import { PinnedComponent } from '../ui/home/pinned';
/** How often the home page refreshes its reading while open. */
const REFRESH_MS = 60_000;
@@ -75,6 +76,7 @@ const REFRESH_MS = 60_000;
WeekAgendaComponent,
HomeKpisComponent,
MyViewsComponent,
PinnedComponent,
PiActionButtonComponent,
PiEmptyStateComponent,
PiSkeletonComponent,
@@ -155,6 +157,7 @@ const REFRESH_MS = 60_000;
</section>
@if (facade.projects().length) {
<ts-pinned />
@if (myHome.home(); as h) {
<ts-home-kpis
[mine]="home.mineTotal()"
@@ -271,6 +274,7 @@ const REFRESH_MS = 60_000;
margin-bottom: 2rem;
}
ts-first-steps,
ts-pinned,
ts-home-kpis,
ts-week-agenda,
ts-my-views {

View File

@@ -19,6 +19,7 @@ import {
import { NgTemplateOutlet } from '@angular/common';
import { PiCheckboxComponent, PiIconComponent, PiNoteComponent } from '@pi/ui';
import { GitIntegrationComponent } from '../ui/git-integration';
import { AlertIntegrationComponent } from '../ui/alert-integration';
import { WebhooksComponent } from '../ui/webhooks';
import { TransferComponent } from '../ui/transfer';
import { AutomationsComponent } from '../ui/automations';
@@ -141,6 +142,7 @@ function idFrom(name: string): string {
PiCheckboxComponent,
RouterLink,
GitIntegrationComponent,
AlertIntegrationComponent,
WebhooksComponent,
TransferComponent,
AutomationsComponent,
@@ -798,6 +800,7 @@ function idFrom(name: string): string {
[projectId]="project.id"
[projectKey]="project.key"
/>
<ts-alert-integration [projectId]="project.id" />
<ts-webhooks [projectId]="project.id" />
}
@case ('data') {

View File

@@ -0,0 +1,340 @@
import {
ChangeDetectionStrategy,
Component,
computed,
DestroyRef,
effect,
inject,
input,
signal,
untracked,
} from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import {
PiLabelDirective,
PiSelectFieldComponent,
type PiSelectFieldOption,
} from '@pi/ui';
import { ToastService } from '@task-stream/web/shared/ui';
import { ProjectFacade } from '@task-stream/web/tracker/application';
import {
AlertIntegration,
AlertsGateway,
IssuePriority,
} from '@task-stream/web/tracker/domain';
import { options, PRIORITY } from './copy';
/** The project's first bug type: what it opens when none is chosen. */
const FIRST_BUG = '';
/**
* Settings card for alerts → bugs: the secret URL monitoring tools call,
* what it opens, and how to set it up in each tool.
*/
@Component({
selector: 'ts-alert-integration',
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [PiSelectFieldComponent, PiLabelDirective],
styleUrl: './settings-card.scss',
template: `
<section class="ts-panel ts-block ts-alerts" aria-labelledby="alerts-title">
<div>
<h3 id="alerts-title">Alertas → bugs</h3>
<p class="ts-muted">
Sentry, Uptime Kuma, Grafana o cualquier herramienta que llame a una
URL abre aquí un bug cuando algo falla. Si el mismo problema se
repite, va al bug que abrió mientras siga abierto (con un comentario
como mucho cada media hora); si se resuelve, lo dice en él.
</p>
</div>
@if (integration(); as alerts) {
@if (alerts.enabled) {
@if (alerts.webhookPath) {
<dl class="ts-alerts__facts">
<dt>URL secreta</dt>
<dd>
<code>{{
shown() ? url(alerts.webhookPath) : masked(alerts.webhookPath)
}}</code>
<button
type="button"
class="ts-plain-link"
(click)="shown.set(!shown())"
>
{{ shown() ? 'Ocultar' : 'Ver' }}
</button>
<button
type="button"
class="ts-plain-link"
(click)="copy(url(alerts.webhookPath))"
>
Copiar
</button>
</dd>
</dl>
}
<div class="ts-alerts__settings">
<pi-select-field
[options]="typeOptions()"
[value]="alerts.typeId ?? firstBug"
(valueChange)="save({ typeId: $event || null })"
>
<pi-label>Abre un</pi-label>
</pi-select-field>
<pi-select-field
[options]="priorityOptions"
[value]="alerts.priority"
(valueChange)="save({ priority: $any($event) })"
>
<pi-label>Con prioridad</pi-label>
</pi-select-field>
</div>
<details class="ts-alerts__how">
<summary>Cómo conectarlo</summary>
<dl>
<dt>Sentry</dt>
<dd>
<strong
>Settings → Developer Settings → Custom Integration</strong
>
(interna): pega la URL como «Webhook URL», marca
<em>issue</em> en «Webhooks» y crea una regla de alerta que la
avise. Un error nuevo abre un bug; si lo resuelves en Sentry, se
dice en el bug.
</dd>
<dt>Uptime Kuma</dt>
<dd>
<strong>Ajustes → Notificaciones → Webhook</strong>, tipo
<code>application/json</code>, y actívala en los monitores.
Caído abre un bug; de nuevo arriba, lo dice en él.
</dd>
<dt>Grafana o Alertmanager</dt>
<dd>
Un <em>contact point</em> (o <em>receiver</em>) de tipo Webhook
con la URL. Agrupa por alerta: cada grupo es un bug.
</dd>
<dt>Cualquier otra</dt>
<dd>
Un <code>POST</code> con JSON: <code>title</code> y, si quieres,
<code>fingerprint</code> (lo que hace que dos avisos sean el
mismo problema), <code>description</code>, <code>url</code> y
<code>resolved</code>.
</dd>
</dl>
</details>
@if (alerts.webhookPath) {
<div class="ts-alerts__actions">
<button
type="button"
class="ts-btn ts-btn--secondary"
(click)="rotate()"
>
Cambiar la URL
</button>
<button
type="button"
class="ts-btn ts-btn--ghost"
(click)="disable()"
>
Desactivar
</button>
</div>
}
} @else {
<div>
<button
type="button"
class="ts-btn ts-btn--primary"
(click)="save({})"
>
Activar
</button>
</div>
}
}
</section>
`,
styles: `
.ts-alerts {
display: grid;
gap: 0.875rem;
h3 {
margin: 0 0 0.25rem;
}
}
.ts-alerts__facts {
display: grid;
grid-template-columns: auto minmax(0, 1fr);
gap: 0.375rem 1rem;
margin: 0;
dt {
color: var(--ts-muted);
font-size: 0.875rem;
}
dd {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
align-items: baseline;
margin: 0;
min-width: 0;
}
}
code {
overflow-wrap: anywhere;
}
.ts-alerts__settings {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(min(100%, 14rem), 1fr));
gap: 0.75rem;
}
.ts-alerts__how {
font-size: 0.875rem;
summary {
cursor: pointer;
font-weight: 600;
}
dl {
display: grid;
gap: 0.25rem;
margin: 0.75rem 0 0;
}
dt {
margin-top: 0.5rem;
font-weight: 600;
}
dd {
margin: 0;
color: var(--ts-ink-soft);
line-height: 1.5;
}
}
.ts-alerts__actions {
display: flex;
flex-wrap: wrap;
gap: 0.25rem;
}
`,
})
export class AlertIntegrationComponent {
readonly projectId = input.required<string>();
private readonly alerts = inject(AlertsGateway);
private readonly project = inject(ProjectFacade);
private readonly toast = inject(ToastService);
private readonly destroyRef = inject(DestroyRef);
protected readonly integration = signal<AlertIntegration | null>(null);
protected readonly shown = signal(false);
protected readonly firstBug = FIRST_BUG;
protected readonly priorityOptions = options(PRIORITY);
protected readonly typeOptions = computed<PiSelectFieldOption<string>[]>(
() => [
{ value: FIRST_BUG, label: 'El primer tipo de bug' },
...this.project
.workflow()
.settings.issueTypes.filter((t) => !t.archived)
.map((t) => ({ value: t.id, label: t.name })),
],
);
constructor() {
effect(() => {
const projectId = this.projectId();
untracked(() =>
this.alerts
.integration(projectId)
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe({
next: (alerts) => this.integration.set(alerts),
error: () => this.toast.error('No se pudo leer la integración'),
}),
);
});
}
/** The API is served from the same origin as the web app. */
protected url(path: string): string {
return `${location.origin}${path}`;
}
/** The URL without its secret, until asked to show it. */
protected masked(path: string): string {
return this.url(path.slice(0, path.lastIndexOf('/') + 1)) + '••••••••••••';
}
protected save(change: {
typeId?: string | null;
priority?: IssuePriority;
}): void {
const current = this.integration();
const wasOn = !!current?.enabled;
this.alerts
.save(this.projectId(), {
typeId:
change.typeId === undefined
? (current?.typeId ?? null)
: change.typeId,
priority: change.priority ?? current?.priority ?? IssuePriority.HIGH,
})
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe({
next: (alerts) => {
this.integration.set(alerts);
if (!wasOn) this.shown.set(true);
this.toast.success(
wasOn ? 'Guardado' : 'Activado: copia la URL en tu herramienta',
);
},
error: () => this.toast.error('No se pudo guardar'),
});
}
protected rotate(): void {
if (
!confirm(
'La URL actual dejará de valer: habrá que cambiarla en cada herramienta. ¿Seguir?',
)
)
return;
this.alerts
.rotateToken(this.projectId())
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe({
next: (alerts) => {
this.integration.set(alerts);
this.shown.set(true);
this.toast.success('URL cambiada');
},
error: () => this.toast.error('No se pudo cambiar la URL'),
});
}
protected disable(): void {
if (
!confirm(
'Las alertas dejarán de abrir bugs (los ya abiertos se quedan). ¿Desactivar?',
)
)
return;
this.alerts
.disable(this.projectId())
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe({
next: () => {
this.integration.update((alerts) =>
alerts ? { ...alerts, enabled: false, webhookPath: null } : alerts,
);
this.shown.set(false);
this.toast.success('Desactivado');
},
error: () => this.toast.error('No se pudo desactivar'),
});
}
protected copy(text: string): void {
navigator.clipboard
?.writeText(text)
.then(() => this.toast.success('Copiada'))
.catch(() => this.toast.error('No se pudo copiar'));
}
}

View File

@@ -14,6 +14,7 @@ import { takeUntilDestroyed, toObservable } from '@angular/core/rxjs-interop';
import { Router } from '@angular/router';
import { ProjectsFacade } from '@task-stream/web/tracker/application';
import {
Decision,
GO_TO,
isTyping,
IssuesGateway,
@@ -23,6 +24,7 @@ import {
} from '@task-stream/web/tracker/domain';
import {
catchError,
combineLatest,
debounceTime,
distinctUntilChanged,
map,
@@ -30,9 +32,37 @@ import {
switchMap,
} from 'rxjs';
/** A ticket as a result of the palette. */
const issueResult = (i: IssueSummary): Result => ({
id: `i:${i.id}`,
kind: 'issue',
label: `${i.key} · ${i.title}`,
detail: i.assignee?.name ?? '',
url: `/p/${i.key.slice(0, i.key.lastIndexOf('-'))}/issues/${i.key}`,
});
/** A decision as a result: opens its ticket at the comment. */
const decisionResult = ({ comment, issue }: Decision): Result => ({
id: `d:${comment.id}`,
kind: 'decision',
label: `${issue.key} · ${excerpt(comment.body)}`,
detail: comment.author.name,
url: `/p/${issue.key.slice(0, issue.key.lastIndexOf('-'))}/issues/${issue.key}?comment=${comment.id}`,
});
/** The first words of a comment, without Markdown marks. */
function excerpt(body: string): string {
const text = body
.replace(/!?\[([^\]]*)\]\([^)]*\)/g, '$1')
.replace(/[*_`~#>]+/g, '')
.replace(/\s+/g, ' ')
.trim();
return text.length > 90 ? `${text.slice(0, 89)}…` : text;
}
interface Result {
id: string;
kind: 'project' | 'issue';
kind: 'project' | 'issue' | 'decision';
label: string;
detail: string;
url: string;
@@ -74,7 +104,7 @@ interface Result {
#search
class="ts-palette__input"
type="search"
placeholder="Busca un ticket (WEB-12, título…) o un proyecto"
placeholder="Busca un ticket (WEB-12, título…), una decisión o un proyecto"
role="combobox"
aria-controls="ts-palette-results"
[attr.aria-expanded]="results().length > 0"
@@ -102,7 +132,13 @@ interface Result {
(keydown.enter)="go(result)"
>
<span class="ts-palette__kind">{{
result.kind === 'project' ? 'Proyecto' : 'Ticket'
result.kind === 'project'
? 'Proyecto'
: result.kind === 'decision'
? 'Decisión'
: text().trim().length < 2
? 'Fijado'
: 'Ticket'
}}</span>
<span class="ts-palette__label">{{ result.label }}</span>
<span class="ts-palette__detail">{{ result.detail }}</span>
@@ -242,6 +278,7 @@ export class CommandPalette {
private readonly router = inject(Router);
private readonly projects = inject(ProjectsFacade);
private readonly issues = inject(IssuesGateway);
private readonly destroyRef = inject(DestroyRef);
private readonly searchInput =
viewChild<ElementRef<HTMLInputElement>>('search');
@@ -254,9 +291,12 @@ export class CommandPalette {
private goTimer: ReturnType<typeof setTimeout> | null = null;
private readonly foundIssues = signal<IssueSummary[]>([]);
private readonly foundDecisions = signal<Decision[]>([]);
/** Mine, shown before typing; read again each time it opens. */
protected readonly pins = signal<IssueSummary[]>([]);
protected readonly results = computed<Result[]>(() => {
const query = this.text().trim().toLowerCase();
if (query.length < 2) return [];
if (query.length < 2) return this.pins().map(issueResult);
const projects = this.projects
.sorted()
.filter(
@@ -272,14 +312,11 @@ export class CommandPalette {
detail: p.key,
url: `/p/${p.key}/board`,
}));
const issues = this.foundIssues().map((i) => ({
id: `i:${i.id}`,
kind: 'issue' as const,
label: `${i.key} · ${i.title}`,
detail: i.assignee?.name ?? '',
url: `/p/${i.key.slice(0, i.key.lastIndexOf('-'))}/issues/${i.key}`,
}));
return [...projects, ...issues];
return [
...projects,
...this.foundIssues().map(issueResult),
...this.foundDecisions().map(decisionResult),
];
});
constructor() {
@@ -290,15 +327,25 @@ export class CommandPalette {
distinctUntilChanged(),
switchMap((search) =>
search.length < 2
? of([])
: this.issues.mine({ assignedToMe: false, search, limit: 8 }).pipe(
map((page) => page.items),
catchError(() => of([])),
),
? of<[IssueSummary[], Decision[]]>([[], []])
: combineLatest([
this.issues
.mine({ assignedToMe: false, search, limit: 8 })
.pipe(
map((page) => page.items),
catchError(() => of([])),
),
this.issues
.searchDecisions(search, 4)
.pipe(catchError(() => of([]))),
]),
),
takeUntilDestroyed(inject(DestroyRef)),
takeUntilDestroyed(this.destroyRef),
)
.subscribe((found) => this.foundIssues.set(found));
.subscribe((found) => {
this.foundIssues.set(found[0]);
this.foundDecisions.set(found[1]);
});
effect(() => {
if (this.mode() === 'search') this.searchInput()?.nativeElement.focus();
});
@@ -349,8 +396,16 @@ export class CommandPalette {
}
protected open(mode: 'search' | 'help'): void {
if (mode === 'search' && !this.projects.sorted().length)
this.projects.load();
if (mode === 'search') {
if (!this.projects.sorted().length) this.projects.load();
this.issues
.pins()
.pipe(
catchError(() => of([])),
takeUntilDestroyed(this.destroyRef),
)
.subscribe((pins) => this.pins.set(pins));
}
this.mode.set(mode);
}

View File

@@ -0,0 +1,172 @@
import {
ChangeDetectionStrategy,
Component,
DestroyRef,
inject,
signal,
} from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { RouterLink } from '@angular/router';
import { PiIconComponent } from '@pi/ui';
import { ToastService } from '@task-stream/web/shared/ui';
import {
IssuesGateway,
IssueSummary,
StatusCategory,
} from '@task-stream/web/tracker/domain';
/**
* The tickets I pinned, at hand on the home page: one strip, only when
* there are some (they are pinned from the ticket's page).
*/
@Component({
selector: 'ts-pinned',
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [RouterLink, PiIconComponent],
template: `
@if (pins().length) {
<section class="ts-pinned" aria-labelledby="pinned-title">
<h2 id="pinned-title">
<pi-icon icon="pin" size="small" />
Fijados
</h2>
<ul role="list">
@for (pin of pins(); track pin.id) {
<li [class.is-done]="pin.statusCategory === done">
<a [routerLink]="['/p', projectOf(pin.key), 'issues', pin.key]">
<span class="ts-pinned__key">{{ pin.key }}</span>
<span class="ts-pinned__title">{{ pin.title }}</span>
</a>
<button
type="button"
class="ts-pinned__unpin"
[attr.aria-label]="'Quitar ' + pin.key + ' de fijados'"
title="Quitar de fijados"
(click)="unpin(pin)"
>
×
</button>
</li>
}
</ul>
</section>
}
`,
host: { '[style.display]': "pins().length ? 'block' : 'none'" },
styles: `
.ts-pinned {
display: flex;
align-items: center;
gap: 0.75rem 1rem;
flex-wrap: wrap;
}
h2 {
display: inline-flex;
align-items: center;
gap: 0.375rem;
margin: 0;
color: var(--ts-muted);
font-size: 0.75rem;
font-weight: 600;
letter-spacing: 0.04em;
text-transform: uppercase;
}
ul {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
margin: 0;
padding: 0;
list-style: none;
}
li {
display: inline-flex;
align-items: center;
max-width: min(100%, 22rem);
border: 1px solid var(--ts-line);
border-radius: 999px;
background: var(--ts-surface);
&:hover,
&:focus-within {
border-color: color-mix(in srgb, var(--ts-brand) 45%, var(--ts-line));
}
&.is-done .ts-pinned__title {
color: var(--ts-muted);
text-decoration: line-through;
}
}
a {
display: inline-flex;
align-items: baseline;
gap: 0.5rem;
min-width: 0;
padding: 0.375rem 0.25rem 0.375rem 0.875rem;
color: inherit;
font-size: 0.875rem;
text-decoration: none;
}
.ts-pinned__key {
flex: none;
color: var(--ts-muted);
font-family: var(--ts-font-mono);
font-size: 0.75rem;
}
.ts-pinned__title {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* A plain glyph, not @pi/ui's sized button. */
.ts-pinned__unpin {
flex: none;
width: 1.75rem !important;
min-width: 0 !important;
height: 1.75rem !important;
margin-right: 0.25rem;
padding: 0 !important;
border: 0;
border-radius: 50%;
background: transparent;
color: var(--ts-muted);
font-size: 1.125rem;
line-height: 1;
cursor: pointer;
&:hover,
&:focus-visible {
background: var(--ts-sunken);
color: var(--ts-ink);
}
}
`,
})
export class PinnedComponent {
private readonly gateway = inject(IssuesGateway);
private readonly toast = inject(ToastService);
private readonly destroyRef = inject(DestroyRef);
protected readonly pins = signal<IssueSummary[]>([]);
protected readonly done = StatusCategory.DONE;
constructor() {
this.gateway
.pins()
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe({ next: (pins) => this.pins.set(pins), error: () => null });
}
protected projectOf(key: string): string {
return key.slice(0, key.lastIndexOf('-'));
}
protected unpin(pin: IssueSummary): void {
this.pins.update((list) => list.filter((p) => p.id !== pin.id));
this.gateway
.pin(pin.id, false)
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe({
error: () => {
this.toast.error(`No se pudo quitar ${pin.key} de fijados`);
this.pins.update((list) => [pin, ...list]);
},
});
}
}

View File

@@ -4,16 +4,35 @@ import {
Component,
computed,
input,
output,
} from '@angular/core';
import { RouterLink } from '@angular/router';
import { PiIconComponent } from '@pi/ui';
import {
DueState,
dueState,
IssuePriority,
IssueSummary,
POINT_SCALE,
Workflow,
} from '@task-stream/web/tracker/domain';
import { initials, PRIORITY, PRIORITY_LEVEL, typeLook } from './copy';
import { CardChange } from '@task-stream/web/tracker/application';
import {
initials,
pointOptions,
PRIORITY,
PRIORITY_LEVEL,
typeLook,
} from './copy';
/** Most urgent first, as the menus list them. */
const PRIORITY_ORDER = [
IssuePriority.CRITICAL,
IssuePriority.HIGH,
IssuePriority.MEDIUM,
IssuePriority.LOW,
IssuePriority.LOWEST,
];
/** A ticket on the board / in lists: type stripe, key, title, signals. */
@Component({
@@ -21,9 +40,8 @@ import { initials, PRIORITY, PRIORITY_LEVEL, typeLook } from './copy';
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [RouterLink, PiIconComponent, BlockedComponent],
template: `
<a
<div
class="ts-card"
[routerLink]="['..', 'issues', issue().key]"
[style.--type]="type().tone"
[class.ts-card--critical]="issue().priority === 'CRITICAL'"
>
@@ -33,21 +51,45 @@ import { initials, PRIORITY, PRIORITY_LEVEL, typeLook } from './copy';
</span>
<span class="ts-card__key">{{ issue().key }}</span>
@if (issue().blockedBy.length) {
<ts-blocked [blockers]="issue().blockedBy" [compact]="true" />
<ts-blocked
class="ts-card__above"
[blockers]="issue().blockedBy"
[compact]="true"
/>
}
@if (issue().area; as area) {
<span class="ts-card__area">{{ areaLabel(area) }}</span>
}
</div>
<p class="ts-card__title">{{ issue().title }}</p>
<!-- The title is the link; it covers the card (the selects stay above it). -->
<a class="ts-card__title" [routerLink]="['..', 'issues', issue().key]">{{
issue().title
}}</a>
<div class="ts-card__bottom">
<span
class="ts-signal"
[attr.data-level]="level()"
[attr.title]="'Prioridad ' + priority().label.toLowerCase()"
[attr.aria-label]="'Prioridad ' + priority().label.toLowerCase()"
>
<i></i><i></i><i></i><i></i>
<span class="ts-card__edit" [class.is-editable]="editable()">
<span
class="ts-signal"
[attr.data-level]="level()"
[attr.title]="'Prioridad ' + priority().label.toLowerCase()"
[attr.aria-label]="'Prioridad ' + priority().label.toLowerCase()"
>
<i></i><i></i><i></i><i></i>
</span>
@if (editable()) {
<select
[attr.aria-label]="'Prioridad de ' + issue().key"
(change)="pick('priority', $any($event.target).value)"
>
@for (option of priorities; track option.value) {
<option
[value]="option.value"
[selected]="option.value === issue().priority"
>
{{ option.label }}
</option>
}
</select>
}
</span>
@if (issue().parent; as parent) {
<span class="ts-card__meta" [attr.title]="'Cuelga de ' + parent.title"
@@ -62,10 +104,32 @@ import { initials, PRIORITY, PRIORITY_LEVEL, typeLook } from './copy';
>{{ dueLabel(state) }}</span
>
}
@if (issue().storyPoints !== null) {
<span class="ts-card__points" title="Estimación">{{
issue().storyPoints
}}</span>
@if (issue().storyPoints !== null || editable()) {
<span class="ts-card__edit" [class.is-editable]="editable()">
<span
class="ts-card__points"
[class.is-empty]="issue().storyPoints === null"
title="Estimación"
>{{ issue().storyPoints ?? '–' }}</span
>
@if (editable()) {
<select
[attr.aria-label]="'Estimación de ' + issue().key"
(change)="pick('storyPoints', $any($event.target).value)"
>
@for (option of points(); track option.value) {
<option
[value]="option.value"
[selected]="
option.value === (issue().storyPoints ?? '') + ''
"
>
{{ option.label }}
</option>
}
</select>
}
</span>
}
@if (issue().release; as release) {
<span class="ts-card__meta" title="Release"
@@ -79,17 +143,37 @@ import { initials, PRIORITY, PRIORITY_LEVEL, typeLook } from './copy';
</span>
}
<span class="ts-card__spacer"></span>
@if (issue().assignee; as assignee) {
<span class="ts-card__who" [attr.title]="assignee.name">{{
initialsOf(assignee.name)
}}</span>
} @else {
<span class="ts-card__who ts-card__who--none" title="Sin asignar"
>—</span
>
}
<span class="ts-card__edit" [class.is-editable]="editable()">
@if (issue().assignee; as assignee) {
<span class="ts-card__who" [attr.title]="assignee.name">{{
initialsOf(assignee.name)
}}</span>
} @else {
<span class="ts-card__who ts-card__who--none" title="Sin asignar"
>—</span
>
}
@if (editable()) {
<select
[attr.aria-label]="'Persona asignada a ' + issue().key"
(change)="pick('assigneeId', $any($event.target).value)"
>
<option value="" [selected]="!issue().assignee">
Sin asignar
</option>
@for (member of members(); track member.id) {
<option
[value]="member.id"
[selected]="member.id === issue().assignee?.id"
>
{{ member.name }}
</option>
}
</select>
}
</span>
</div>
</a>
</div>
`,
styles: `
:host {
@@ -156,10 +240,6 @@ import { initials, PRIORITY, PRIORITY_LEVEL, typeLook } from './copy';
color-mix(in srgb, var(--type) 70%, transparent);
transform: translateY(-1px);
}
&:focus-visible {
outline: 2px solid var(--type);
outline-offset: 2px;
}
}
.ts-card__top,
.ts-card__bottom {
@@ -198,6 +278,51 @@ import { initials, PRIORITY, PRIORITY_LEVEL, typeLook } from './copy';
font-weight: 500;
line-height: 1.35;
color: var(--ts-ink);
text-decoration: none;
&::after {
content: '';
position: absolute;
inset: 0;
}
&:focus-visible {
outline: none;
}
}
.ts-card:has(.ts-card__title:focus-visible) {
outline: 2px solid var(--type);
outline-offset: 2px;
}
/* Above the stretched link: what can be clicked on its own. */
.ts-card__above,
.ts-card__edit.is-editable {
position: relative;
z-index: 1;
}
/* A native select laid invisibly over the chip: keyboard, phone and
screen reader for free, never clipped by the card. */
.ts-card__edit {
display: inline-flex;
align-items: center;
select {
position: absolute;
inset: -0.25rem;
width: calc(100% + 0.5rem);
height: calc(100% + 0.5rem);
opacity: 0;
cursor: pointer;
font-size: 1rem;
}
&.is-editable:hover > :first-child,
&.is-editable:focus-within > :first-child {
outline: 2px solid color-mix(in srgb, var(--ts-brand) 60%, transparent);
outline-offset: 2px;
border-radius: 999px;
}
}
.ts-card__points.is-empty {
background: transparent;
border: 1px dashed var(--ts-line-strong);
color: var(--ts-muted);
}
.ts-card__meta {
display: inline-flex;
@@ -274,6 +399,30 @@ export class IssueCardComponent {
return day.toLocaleDateString('es', { day: 'numeric', month: 'short' });
}
readonly issue = input.required<IssueSummary>();
/** Priority, estimate and assignee change right on the card. */
readonly editable = input(false);
/** Who it can be assigned to. */
readonly members = input<{ id: string; name: string }[]>([]);
/** A change picked on the card. */
readonly edited = output<CardChange>();
protected readonly priorities = PRIORITY_ORDER.map((p) => ({
value: p,
label: PRIORITY[p].label,
}));
protected readonly points = computed(() =>
pointOptions(POINT_SCALE, this.issue().storyPoints),
);
protected pick(
field: 'priority' | 'storyPoints' | 'assigneeId',
value: string,
): void {
if (field === 'priority')
this.edited.emit({ priority: value as IssuePriority });
else if (field === 'storyPoints')
this.edited.emit({ storyPoints: value === '' ? null : Number(value) });
else this.edited.emit({ assigneeId: value || null });
}
/** The project's settings: names, icons and colours of types and areas. */
readonly workflow = input.required<Workflow>();
protected readonly type = computed(() =>