Compare commits

...

2 Commits

Author SHA1 Message Date
2030fe0cd9 docs: what was done in the landing and in @pi/ui (chapter 10)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 20:21:38 +02:00
c91df110f8 feat: steadier live updates and notices for public proposals
- The web pings the live connection, reconnects when it goes quiet and
  reloads what each screen shows after reconnecting.
- Dropping a card on a column it cannot go to puts it back.
- Owners and admins get a notice when a proposal arrives.
- The proposal limit lives in Redis, so it holds across instances.
- REST tells whether I voted; pasted images leave the attachment list.
- Deleting a project removes its Git integration; no lint warnings left.
- Reactions and the blocked badge are components of their own.
- Docs: what is done and what is left (chapter 10), Word and PDF.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 19:57:21 +02:00
50 changed files with 617 additions and 323 deletions

View File

@@ -10997,6 +10997,10 @@
"type": "number", "type": "number",
"description": "How many want it done" "description": "How many want it done"
}, },
"voted": {
"type": "boolean",
"description": "Whether I voted for it (false without a token)"
},
"rank": { "rank": {
"type": "string", "type": "string",
"description": "Place in the backlog and its board column (ascending, as text)" "description": "Place in the backlog and its board column (ascending, as text)"
@@ -11064,6 +11068,7 @@
"parent", "parent",
"blockedBy", "blockedBy",
"votes", "votes",
"voted",
"rank", "rank",
"checklist", "checklist",
"trashedAt", "trashedAt",
@@ -12673,6 +12678,7 @@
"COMMENTED", "COMMENTED",
"MENTIONED", "MENTIONED",
"REPLIED", "REPLIED",
"PROPOSED",
"WATCHED", "WATCHED",
"ADDED_TO_PROJECT", "ADDED_TO_PROJECT",
"REMINDER" "REMINDER"

View File

@@ -824,6 +824,7 @@ enum NotificationKind {
ASSIGNED ASSIGNED
COMMENTED COMMENTED
MENTIONED MENTIONED
PROPOSED
QA_FAILED QA_FAILED
REMINDER REMINDER
REPLIED REPLIED

View File

@@ -1875,6 +1875,8 @@ export interface components {
blockedBy: components["schemas"]["IssueParentDto"][]; blockedBy: components["schemas"]["IssueParentDto"][];
/** @description How many want it done */ /** @description How many want it done */
votes: number; votes: number;
/** @description Whether I voted for it (false without a token) */
voted: boolean;
/** @description Place in the backlog and its board column (ascending, as text) */ /** @description Place in the backlog and its board column (ascending, as text) */
rank: string; rank: string;
checklist: components["schemas"]["ChecklistItemDto"][]; checklist: components["schemas"]["ChecklistItemDto"][];
@@ -2438,7 +2440,7 @@ export interface components {
path: string | null; path: string | null;
}; };
/** @enum {string} */ /** @enum {string} */
NotificationKind: "ASSIGNED" | "STATUS_CHANGED" | "QA_FAILED" | "COMMENTED" | "MENTIONED" | "REPLIED" | "WATCHED" | "ADDED_TO_PROJECT" | "REMINDER"; NotificationKind: "ASSIGNED" | "STATUS_CHANGED" | "QA_FAILED" | "COMMENTED" | "MENTIONED" | "REPLIED" | "PROPOSED" | "WATCHED" | "ADDED_TO_PROJECT" | "REMINDER";
NotificationDto: { NotificationDto: {
id: string; id: string;
kind: components["schemas"]["NotificationKind"]; kind: components["schemas"]["NotificationKind"];

View File

@@ -61,12 +61,12 @@ lee igual que un anónimo. Las subscriptions exigen sesión; con ella, también
`IDEA`, `title`, `description`, `author` opcional) crea un ticket con la etiqueta `propuesta`, a nombre de TaskStream, `IDEA`, `title`, `description`, `author` opcional) crea un ticket con la etiqueta `propuesta`, a nombre de TaskStream,
en el primer estado, de tipo Bug o Feature (o el primer tipo activo). No se pide correo, porque quedaría a la vista. Contra en el primer estado, de tipo Bug o Feature (o el primer tipo activo). No se pide correo, porque quedaría a la vista. Contra
el spam, el campo `website` es una trampa (si llega relleno se responde como si nada y no se guarda) y cada dirección el spam, el campo `website` es una trampa (si llega relleno se responde como si nada y no se guarda) y cada dirección
envía como mucho 5 cada 15 minutos (429); el límite vive en memoria de cada instancia, así que con varias es envía como mucho 5 cada 15 minutos (429); el contador vive en Redis (`RateLimitStore`), así que vale igual con
aproximado. La web tiene el formulario en `/p/{CLAVE}/proponer`, enlazado en la cabecera del proyecto. varias instancias. Los propietarios y admins del proyecto reciben un aviso (`PROPOSED`). La web tiene el formulario en `/p/{CLAVE}/proponer`, enlazado en la cabecera del proyecto.
**Votos**: quien tenga sesión vota los tickets de un proyecto público, y los miembros los de uno privado **Votos**: quien tenga sesión vota los tickets de un proyecto público, y los miembros los de uno privado
(`voteIssue(issueId, on)`, `PUT/DELETE /api/issues/{issue}/vote`). Cada ticket da `votes` y, en GraphQL, `voted` (si he (`voteIssue(issueId, on)`, `PUT/DELETE /api/issues/{issue}/vote`). Cada ticket da `votes` y `voted` (si he votado
votado yo); la lista de quién votó no se publica. yo; en REST, solo con token); la lista de quién votó no se publica.
`publicProjects` (GraphQL) y `GET /api/explore/projects` (REST, sin token) los listan. Al volver a privado, quien no es `publicProjects` (GraphQL) y `GET /api/explore/projects` (REST, sin token) los listan. Al volver a privado, quien no es
miembro vuelve a recibir _not found_. Los enlaces a tickets de otros proyectos solo se muestran si el visitante puede miembro vuelve a recibir _not found_. Los enlaces a tickets de otros proyectos solo se muestran si el visitante puede
@@ -265,6 +265,7 @@ ningún agregado, solo cada uno lee y descarta las suyas.
| Alguien comenta | Quien lo tiene asignado y quien lo abrió | `COMMENTED` | | Alguien comenta | Quien lo tiene asignado y quien lo abrió | `COMMENTED` |
| Alguien responde a un comentario | Quien escribió el comentario | `REPLIED` | | Alguien responde a un comentario | Quien escribió el comentario | `REPLIED` |
| Te añaden a un proyecto | La persona añadida | `ADDED_TO_PROJECT` | | Te añaden a un proyecto | La persona añadida | `ADDED_TO_PROJECT` |
| Llega una propuesta del público | Propietarios y admins del proyecto | `PROPOSED` |
**Recordatorios** (`REMINDER`): cada hora la API mira los tickets sin terminar con fecha límite y avisa a quien lo **Recordatorios** (`REMINDER`): cada hora la API mira los tickets sin terminar con fecha límite y avisa a quien lo
tiene asignado y a quien lo sigue el día antes, el mismo día y una vez cuando ya ha vencido (el día cambia en tiene asignado y a quien lo sigue el día antes, el mismo día y una vez cuando ya ha vencido (el día cambia en

View File

@@ -141,6 +141,10 @@ de identidad (`SessionTokenSource`) le pide el token para el cliente GraphQL.
fuente de verdad. fuente de verdad.
- Las subscriptions van por `graphql-ws` con el token en `connectionParams`; si la API responde `UNAUTHENTICATED`, la - Las subscriptions van por `graphql-ws` con el token en `connectionParams`; si la API responde `UNAUTHENTICATED`, la
sesión se cierra. sesión se cierra.
- El cliente manda un _ping_ cada 10 s y, si no llega el _pong_ en 5 s, cierra el socket y reconecta (una conexión
medio muerta, p. ej. tras suspender el portátil o pasar por un proxy, no se nota de otro modo). Al reconectar,
`RealtimeConnection.reconnected$` avisa y cada store vuelve a pedir lo que muestra, porque lo que pasó mientras
tanto no llega por las subscriptions.
- Las operaciones están en `libs/web/*/infrastructure/src/lib/graphql/*.graphql`. Tras cambiar la API o una operación: - Las operaciones están en `libs/web/*/infrastructure/src/lib/graphql/*.graphql`. Tras cambiar la API o una operación:
```sh ```sh

View File

@@ -7,8 +7,8 @@ cuando hay pistas, por dónde empezar.
## 10.1 Para dar el producto por terminado ## 10.1 Para dar el producto por terminado
| Qué | Por qué | Por dónde empezar | | Qué | Por qué | Por dónde empezar |
| -------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | ---------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Averiguar por qué se pierde el tiempo real en producción | La ficha del ticket ya no depende de él (se recarga tras cada cambio propio), pero el tablero, la actividad y la campana sí | Sospecha: el WebSocket manda el token solo al conectar (`connectionParams`); cuando caduca o se renueva, la conexión sigue viva pero puede dejar de recibir. Dejar una pestaña abierta más que la vida del token | | Comprobar en producción que el tiempo real ya no se pierde | Ya hay _ping_ cada 10 s, reconexión si no hay respuesta y recarga de cada pantalla al reconectar, pero no se ha probado allí | Dejar una pestaña abierta más que la vida del token y tras suspender el portátil, y mover tickets desde otra. Si aún falla, mirar el token de `connectionParams`, que solo se manda al conectar |
| Pruebas de navegador en la integración continua | Los fallos del tablero (no se podía arrastrar entre columnas) y de la ficha (no se refrescaba) solo se vieron usándolo | Ya existe la receta: API real con usuarios simulados (`IdentityProvider` sustituido), `nx serve web` y Playwright con la sesión en `localStorage` (`task-stream.session`). Convertirla en `apps/web-e2e` | | Pruebas de navegador en la integración continua | Los fallos del tablero (no se podía arrastrar entre columnas) y de la ficha (no se refrescaba) solo se vieron usándolo | Ya existe la receta: API real con usuarios simulados (`IdentityProvider` sustituido), `nx serve web` y Playwright con la sesión en `localStorage` (`task-stream.session`). Convertirla en `apps/web-e2e` |
| Copias de seguridad de Postgres y Mongo | Postgres guarda el event store, la fuente de verdad; Mongo se puede reconstruir desde él, Postgres no | Copias programadas en Dokploy y **una restauración de prueba** | | Copias de seguridad de Postgres y Mongo | Postgres guarda el event store, la fuente de verdad; Mongo se puede reconstruir desde él, Postgres no | Copias programadas en Dokploy y **una restauración de prueba** |
| Limpiar los datos de prueba de producción | DEMO, TEST y los tickets de las pruebas del MCP | Borrar los proyectos desde Configuración (borrado definitivo con su clave) | | Limpiar los datos de prueba de producción | DEMO, TEST y los tickets de las pruebas del MCP | Borrar los proyectos desde Configuración (borrado definitivo con su clave) |
@@ -21,19 +21,11 @@ cuando hay pistas, por dónde empezar.
- **Error al crear subtareas en producción**: lo reportó el usuario y no se reprodujo en local; con el arreglo de la - **Error al crear subtareas en producción**: lo reportó el usuario y no se reprodujo en local; con el arreglo de la
ficha la lista ya se refresca, pero falta el mensaje exacto si vuelve a salir. ficha la lista ya se refresca, pero falta el mensaje exacto si vuelve a salir.
- **Arrastrar a una columna prohibida**: la tarjeta se queda en la última columna permitida por la que pasó el cursor - **Tamaño de la web**: el paquete inicial supera en ~130 kB el aviso de 2 MB por `@pi/ui`, que registraba Chart.js
(comportamiento de `@angular/cdk/drag-drop`). Debería volver a su sitio. al importarse e importaba `lodash` entero. Ya está corregido en `pireon-ui` (con él, 1,90 MB); falta publicar esa
- **Tamaño de la web**: el paquete inicial supera en ~130 kB el aviso de 2 MB, y los estilos de la ficha del ticket versión en Nexus y subir aquí `@pi/ui`.
(`issue.page.scss`) pasan de los 8 kB. Mirar qué entra en el paquete inicial (`marked` y `dompurify` deberían ir solo - **Imágenes pegadas**: ya no salen en la lista de adjuntos, pero si se borran del texto el adjunto sigue guardado.
con la ficha) y sacar las reacciones a su componente.
- **Imágenes pegadas**: también aparecen en la lista de adjuntos del ticket, y si se borran del texto el adjunto sigue
ahí.
- **Límite de propuestas por instancia**: vive en memoria, así que con varias instancias de la API es aproximado.
Pasarlo a Redis si hay más de una.
- **Votos por REST**: dan el total (`votes`) pero no si he votado yo (`voted` solo está en GraphQL).
- **Seguimiento**: los tickets que ya existían no se apuntan a quien los creó o los tiene asignados (solo los nuevos). - **Seguimiento**: los tickets que ya existían no se apuntan a quien los creó o los tiene asignados (solo los nuevos).
- **Borrar un proyecto** no borra su documento de integración con Git (`git_integrations`); no molesta, pero queda.
- **Aviso de lint** en `automation-rules.ts` (una aserción `!`), anterior a estos cambios.
- **El MCP no hace el flujo OAuth del protocolo**: el cliente tiene que llevar un token de Pireon en la cabecera y - **El MCP no hace el flujo OAuth del protocolo**: el cliente tiene que llevar un token de Pireon en la cabecera y
renovarlo a mano cuando caduca. renovarlo a mano cuando caduca.
@@ -45,17 +37,11 @@ En orden de lo que más aportaría:
- resumir un ticket largo con sus comentarios; - resumir un ticket largo con sus comentarios;
- al crear un ticket, sugerir tipo, prioridad y área, y avisar si se parece a otro que ya existe; - al crear un ticket, sugerir tipo, prioridad y área, y avisar si se parece a otro que ya existe;
- redactar las notas de versión en lenguaje natural a partir del borrador. - redactar las notas de versión en lenguaje natural a partir del borrador.
2. **Avisos de propuestas**: hoy una propuesta llega como ticket con la etiqueta «propuesta», pero nadie recibe aviso. 2. **Avisos a Discord, Slack o Telegram**: los webhooks salientes mandan JSON en bruto, que esos chats no muestran
Avisar a propietarios y admins, y quizá una bandeja para revisarlas.
3. **Propuestas y votos en la web pública** (la landing): el escaparate ya trae `proposals` (si acepta, dónde está el
formulario y las más votadas) y los votos de la hoja de ruta; falta mostrarlos.
4. **Avisos a Discord, Slack o Telegram**: los webhooks salientes mandan JSON en bruto, que esos chats no muestran
bien. Una integración por canal (o un formato por destino en los webhooks) mandaría mensajes ya legibles: «Ana bien. Una integración por canal (o un formato por destino en los webhooks) mandaría mensajes ya legibles: «Ana
movió WEB-12 a En revisión», con enlace. movió WEB-12 a En revisión», con enlace.
5. **Conector de claude.ai**: el MCP necesitaría el flujo OAuth del protocolo, y Pireon publicar su metadata de 3. **Conector de claude.ai**: el MCP necesitaría el flujo OAuth del protocolo, y Pireon publicar su metadata de
autorización y admitir el registro dinámico de clientes. autorización y admitir el registro dinámico de clientes.
6. **Proyectos de la landing que ya existen**: hoy el tablero en TaskStream solo se abre al crear el proyecto; poder
abrirlo después desde el panel.
## 10.4 Descartado por ahora ## 10.4 Descartado por ahora
@@ -64,3 +50,18 @@ Mucho trabajo para lo que aportan hoy; se pueden recuperar si alguien los necesi
- **Registro de horas** dedicadas a cada ticket. - **Registro de horas** dedicadas a cada ticket.
- **Objetivos u OKR** por encima de las épicas. - **Objetivos u OKR** por encima de las épicas.
- **App móvil**: la web ya se adapta a pantallas estrechas. - **App móvil**: la web ya se adapta a pantallas estrechas.
## 10.5 Hecho desde la primera versión de esta lista
- Tiempo real: _ping_ y reconexión del WebSocket, y recarga de lo que se ve al reconectar.
- Arrastrar a una columna prohibida devuelve la tarjeta a su sitio.
- Reacciones y «Bloqueado» en componentes propios (los estilos de la ficha vuelven a caber en 8 kB).
- Límite de propuestas en Redis, válido con varias instancias.
- Aviso `PROPOSED` a propietarios y admins cuando llega una propuesta.
- `voted` también por REST.
- Borrar un proyecto borra su integración con Git.
- Sin avisos de lint.
- La web pública (landing-web) enseña las propuestas más votadas, el enlace al formulario y los votos de la hoja de
ruta.
- Un proyecto de la landing creado sin tablero puede abrirlo después: «Crear en TaskStream» en su ficha o
`taskstream: true` en un `PATCH`.

Binary file not shown.

Binary file not shown.

View File

@@ -177,9 +177,10 @@ export function normalizeRule(
}, },
action: { action: {
kind, kind,
// `check` passed, so there is a value whenever one is needed.
value: check value: check
? kind === AutomationActionKind.ADD_LABEL ? kind === AutomationActionKind.ADD_LABEL
? value!.trim().toLowerCase() ? (value ?? '').trim().toLowerCase()
: value : value
: null, : null,
}, },

View File

@@ -1,3 +1,4 @@
import { GitIntegrationsRepository } from '../ports/git-integrations.repository';
import { EventsHandler, IEventHandler } from '@nestjs/cqrs'; import { EventsHandler, IEventHandler } from '@nestjs/cqrs';
import { ProjectDeletedEvent } from '@task-stream/api/tracker/domain'; import { ProjectDeletedEvent } from '@task-stream/api/tracker/domain';
import { ChangeFeed } from '../ports/change-feed'; import { ChangeFeed } from '../ports/change-feed';
@@ -7,7 +8,8 @@ import { versionOf } from './read-model-notifier';
/** /**
* Removes the project (and its activity and timeline) from the read model, * Removes the project (and its activity and timeline) from the read model,
* and tells its members, whose project lists drop it. Tickets, comments and * forgets its Git repositories' secrets, and tells its members, whose project
* lists drop it. Tickets, comments and
* releases go as the cascade saga deletes them. * releases go as the cascade saga deletes them.
*/ */
@EventsHandler(ProjectDeletedEvent) @EventsHandler(ProjectDeletedEvent)
@@ -18,11 +20,14 @@ export class ProjectDeletedEventHandler
private readonly findProjects: FindProjectsRepository, private readonly findProjects: FindProjectsRepository,
private readonly upsertMaterializedProjectRepository: UpsertMaterializedProjectRepository, private readonly upsertMaterializedProjectRepository: UpsertMaterializedProjectRepository,
private readonly changeFeed: ChangeFeed, private readonly changeFeed: ChangeFeed,
private readonly gitIntegrations: GitIntegrationsRepository,
) {} ) {}
async handle(event: ProjectDeletedEvent) { async handle(event: ProjectDeletedEvent) {
const project = await this.findProjects.findById(event.projectId); const project = await this.findProjects.findById(event.projectId);
await this.upsertMaterializedProjectRepository.remove(event.projectId); await this.upsertMaterializedProjectRepository.remove(event.projectId);
// No webhook of it should be accepted any more.
await this.gitIntegrations.saveAll(event.projectId, []);
if (!project) return; // Already removed (event handled twice). if (!project) return; // Already removed (event handled twice).
await this.changeFeed.publishProject( await this.changeFeed.publishProject(
{ {

View File

@@ -10,10 +10,13 @@ import {
mentionedUserIds, mentionedUserIds,
NotificationKind, NotificationKind,
ProjectMemberAddedEvent, ProjectMemberAddedEvent,
ProjectRole,
StatusCategory, StatusCategory,
SYSTEM_ACTOR_ID,
TrackerEvent, TrackerEvent,
withoutMentionMarkup, withoutMentionMarkup,
} from '@task-stream/api/tracker/domain'; } from '@task-stream/api/tracker/domain';
import { PROPOSAL_LABEL } from '../commands/submit-proposal.command-handler';
import { FindCommentsRepository } from '../ports/find-comments.repository'; import { FindCommentsRepository } from '../ports/find-comments.repository';
import { FindIssuesRepository } from '../ports/find-issues.repository'; import { FindIssuesRepository } from '../ports/find-issues.repository';
import { FindProjectsRepository } from '../ports/find-projects.repository'; import { FindProjectsRepository } from '../ports/find-projects.repository';
@@ -117,6 +120,25 @@ export class NotificationsEventHandler implements IEventHandler<TrackerEvent> {
const memberIds = new Set(project.members.map((m) => m.user.id)); const memberIds = new Set(project.members.map((m) => m.user.id));
const drafts: NotificationDraft[] = []; const drafts: NotificationDraft[] = [];
// A proposal from the public form: whoever runs the project should know.
if (
event instanceof IssueCreatedEvent &&
event.actorId === SYSTEM_ACTOR_ID &&
event.issue.labels?.includes(PROPOSAL_LABEL)
) {
for (const member of project.members) {
if (
member.role !== ProjectRole.OWNER &&
member.role !== ProjectRole.ADMIN
)
continue;
drafts.push({
userId: member.user.id,
kind: NotificationKind.PROPOSED,
summary: `Nueva propuesta en ${project.key}: ${ticket}`,
});
}
}
if (event instanceof IssueCreatedEvent && assigneeId) { if (event instanceof IssueCreatedEvent && assigneeId) {
drafts.push({ drafts.push({
userId: assigneeId, userId: assigneeId,

View File

@@ -1,6 +1,7 @@
import { import {
CommentAddedEvent, CommentAddedEvent,
IssueAssignedEvent, IssueAssignedEvent,
IssueCreatedEvent,
IssuePriority, IssuePriority,
IssueSprintChangedEvent, IssueSprintChangedEvent,
IssueStatusChangedEvent, IssueStatusChangedEvent,
@@ -100,7 +101,7 @@ function setup(mailEnabled = false) {
name: 'Web', name: 'Web',
members: ['ana', 'bob', 'qa', 'pm'].map((id) => ({ members: ['ana', 'bob', 'qa', 'pm'].map((id) => ({
user: { id, name: id }, user: { id, name: id },
role: ProjectRole.MEMBER, role: id === 'ana' ? ProjectRole.OWNER : ProjectRole.MEMBER,
})), })),
}), }),
} as unknown as FindProjectsRepository, } as unknown as FindProjectsRepository,
@@ -298,6 +299,28 @@ describe('notifications', () => {
]); ]);
}); });
it('tells whoever runs the project of a proposal from the public form', async () => {
const { handler, repo } = setup();
await handler.handle(
at(
new IssueCreatedEvent('system', {
id: 'i-9',
projectId: 'p-1',
key: 'WEB-9',
title: 'Exportar a PDF',
reporterId: 'system',
assigneeId: null,
labels: ['propuesta'],
} as never),
),
);
expect(repo.all.map((n) => [n.userId, n.kind])).toEqual([
['ana', NotificationKind.PROPOSED],
]);
expect(repo.all[0].summary).toContain('Nueva propuesta en WEB');
});
it('copies by e-mail only to whoever asked for it, when mail is configured', async () => { it('copies by e-mail only to whoever asked for it, when mail is configured', async () => {
const { handler, repo, mailed } = setup(true); const { handler, repo, mailed } = setup(true);
repo.emailOn.add('bob'); repo.emailOn.add('bob');

View File

@@ -26,3 +26,4 @@ export * from './upsert-materialized-sprint.repository';
export * from './git-integrations.repository'; export * from './git-integrations.repository';
export * from './outgoing-webhooks.repository'; export * from './outgoing-webhooks.repository';
export * from './automations.repository'; export * from './automations.repository';
export * from './rate-limit.store';

View File

@@ -0,0 +1,8 @@
/**
* Counts hits per key within a window, shared by every instance of the API
* (so a limit holds however many there are).
*/
export abstract class RateLimitStore {
/** Counts one more for `key` and answers how many within the window. */
abstract hit(key: string, windowMs: number): Promise<number>;
}

View File

@@ -14,6 +14,8 @@ export enum NotificationKind {
MENTIONED = 'MENTIONED', MENTIONED = 'MENTIONED',
/** Someone answered a comment of yours. */ /** Someone answered a comment of yours. */
REPLIED = 'REPLIED', REPLIED = 'REPLIED',
/** A bug report or an idea came in through a project's public form. */
PROPOSED = 'PROPOSED',
/** A ticket you follow changed. */ /** A ticket you follow changed. */
WATCHED = 'WATCHED', WATCHED = 'WATCHED',
/** You were added to a project. */ /** You were added to a project. */

View File

@@ -0,0 +1,19 @@
import { Inject, Injectable } from '@nestjs/common';
import { REDIS_CLIENT } from '@task-stream/api/shared/infrastructure';
import { RateLimitStore } from '@task-stream/api/tracker/application';
import Redis from 'ioredis';
/** A fixed window per key: the first hit opens it, it expires on its own. */
@Injectable()
export class RedisRateLimitStore extends RateLimitStore {
constructor(@Inject(REDIS_CLIENT) private readonly redis: Redis) {
super();
}
async hit(key: string, windowMs: number): Promise<number> {
const name = `task-stream:rate:${key}`;
const count = await this.redis.incr(name);
if (count === 1) await this.redis.pexpire(name, windowMs);
return count;
}
}

View File

@@ -32,6 +32,7 @@ import {
UpsertMaterializedReleaseRepository, UpsertMaterializedReleaseRepository,
UpsertMaterializedTimelineRepository, UpsertMaterializedTimelineRepository,
UserDirectory, UserDirectory,
RateLimitStore,
} from '@task-stream/api/tracker/application'; } from '@task-stream/api/tracker/application';
import { IdentityUserDirectory } from './identity/identity-user-directory'; import { IdentityUserDirectory } from './identity/identity-user-directory';
import { import {
@@ -64,6 +65,7 @@ import {
import { PostgresReleaseIssuesRepository } from './postgres/release-issues.repository'; import { PostgresReleaseIssuesRepository } from './postgres/release-issues.repository';
import { PostgresTrackerStreamsRepository } from './postgres/tracker-streams.repository'; import { PostgresTrackerStreamsRepository } from './postgres/tracker-streams.repository';
import { RedisChangeFeed } from './realtime/redis-change-feed'; import { RedisChangeFeed } from './realtime/redis-change-feed';
import { RedisRateLimitStore } from './redis/redis-rate-limit.store';
import { MongoNotificationsRepository } from './mongo/notifications.repository'; import { MongoNotificationsRepository } from './mongo/notifications.repository';
import { MongoSavedFiltersRepository } from './mongo/saved-filters.repository'; import { MongoSavedFiltersRepository } from './mongo/saved-filters.repository';
import { MongoGitIntegrationsRepository } from './mongo/git-integrations.repository'; import { MongoGitIntegrationsRepository } from './mongo/git-integrations.repository';
@@ -144,6 +146,7 @@ const PORTS = [
{ provide: NotificationMailer, useClass: SmtpNotificationMailer }, { provide: NotificationMailer, useClass: SmtpNotificationMailer },
// Realtime → Redis pub/sub // Realtime → Redis pub/sub
{ provide: ChangeFeed, useClass: RedisChangeFeed }, { provide: ChangeFeed, useClass: RedisChangeFeed },
{ provide: RateLimitStore, useClass: RedisRateLimitStore },
// Identity context (anti-corruption layer) // Identity context (anti-corruption layer)
{ provide: UserDirectory, useClass: IdentityUserDirectory }, { provide: UserDirectory, useClass: IdentityUserDirectory },
]; ];

View File

@@ -443,6 +443,10 @@ export class IssueDto {
}) })
blockedBy!: IssueParentDto[]; blockedBy!: IssueParentDto[];
@ApiProperty({ description: 'How many want it done' }) votes!: number; @ApiProperty({ description: 'How many want it done' }) votes!: number;
@ApiProperty({
description: 'Whether I voted for it (false without a token)',
})
voted!: boolean;
@ApiProperty({ @ApiProperty({
description: description:
'Place in the backlog and its board column (ascending, as text)', 'Place in the backlog and its board column (ascending, as text)',

View File

@@ -92,7 +92,7 @@ export class ExploreController {
): Promise<ProposalResultDto> { ): Promise<ProposalResultDto> {
// Bots fill in every field: pretend it worked, and keep nothing. // Bots fill in every field: pretend it worked, and keep nothing.
if (body.website?.trim()) return { key: null, path: null }; if (body.website?.trim()) return { key: null, path: null };
this.limiter.take(addressOf(req)); await this.limiter.take(addressOf(req));
const result = await this.projects.propose( const result = await this.projects.propose(
project, project,
body.kind, body.kind,

View File

@@ -135,7 +135,10 @@ export class IssuesController {
limit: query.limit, limit: query.limit,
offset: query.offset, offset: query.offset,
}); });
return { ...page, items: await this.refs.issueDtos(page.items) }; return {
...page,
items: await this.refs.issueDtos(page.items, viewerOf(me)),
};
} }
@Get('projects/:project/issues') @Get('projects/:project/issues')
@@ -173,7 +176,10 @@ export class IssuesController {
limit: query.limit, limit: query.limit,
offset: query.offset, offset: query.offset,
}); });
return { ...page, items: await this.refs.issueDtos(page.items) }; return {
...page,
items: await this.refs.issueDtos(page.items, viewerOf(me)),
};
} }
@Post('projects/:project/issues') @Post('projects/:project/issues')
@@ -219,7 +225,7 @@ export class IssuesController {
return Promise.all( return Promise.all(
epics.map(async (epic) => ({ epics.map(async (epic) => ({
...epic, ...epic,
issue: await this.refs.issueDto(epic.issue), issue: await this.refs.issueDto(epic.issue, viewerOf(me)),
})), })),
); );
} }
@@ -234,7 +240,10 @@ export class IssuesController {
@CurrentUser() me: User | undefined, @CurrentUser() me: User | undefined,
@Param('issue') issue: string, @Param('issue') issue: string,
) { ) {
return this.refs.issueDto(await this.issues.findOne(viewerOf(me), issue)); return this.refs.issueDto(
await this.issues.findOne(viewerOf(me), issue),
viewerOf(me),
);
} }
@Patch('issues/:issue') @Patch('issues/:issue')
@@ -392,6 +401,7 @@ export class IssuesController {
viewerOf(me), viewerOf(me),
await this.refs.projectId(viewerOf(me), project), await this.refs.projectId(viewerOf(me), project),
), ),
viewerOf(me),
); );
} }

View File

@@ -194,7 +194,7 @@ export class ProjectsController {
return Promise.all( return Promise.all(
columns.map(async (column) => ({ columns.map(async (column) => ({
...column, ...column,
issues: await this.refs.issueDtos(column.issues), issues: await this.refs.issueDtos(column.issues, viewerOf(me)),
})), })),
); );
} }

View File

@@ -1,33 +1,24 @@
import { HttpException, HttpStatus, Injectable } from '@nestjs/common'; import { HttpException, HttpStatus, Injectable } from '@nestjs/common';
import { RateLimitStore } from '@task-stream/api/tracker/application';
/** Proposals from one address within the window. */ /** Proposals from one address within the window. */
const LIMIT = 5; const LIMIT = 5;
const WINDOW_MS = 15 * 60_000; const WINDOW_MS = 15 * 60_000;
/** /**
* Keeps one address from flooding a project with proposals. In memory: * Keeps one address from flooding a project with proposals. Counted in
* enough for one instance, and an approximation with several (each counts * Redis, so the limit holds across every instance of the API.
* its own), which is fine for what it guards against.
*/ */
@Injectable() @Injectable()
export class ProposalLimiter { export class ProposalLimiter {
private readonly sent = new Map<string, number[]>(); constructor(private readonly store: RateLimitStore) {}
take(address: string): void { async take(address: string): Promise<void> {
const now = Date.now(); const count = await this.store.hit(`proposals:${address}`, WINDOW_MS);
const recent = (this.sent.get(address) ?? []).filter( if (count > LIMIT)
(at) => now - at < WINDOW_MS,
);
if (recent.length >= LIMIT)
throw new HttpException( throw new HttpException(
'Too many proposals from here; try again in a few minutes', 'Too many proposals from here; try again in a few minutes',
HttpStatus.TOO_MANY_REQUESTS, HttpStatus.TOO_MANY_REQUESTS,
); );
recent.push(now);
this.sent.set(address, recent);
// Forget quiet addresses now and then, so the map does not grow forever.
if (this.sent.size > 10_000)
for (const [key, times] of this.sent)
if (times.every((at) => now - at >= WINDOW_MS)) this.sent.delete(key);
} }
} }

View File

@@ -37,19 +37,27 @@ export class Refs {
return (await this.issues.findOne(viewerId, ref)).id; return (await this.issues.findOne(viewerId, ref)).id;
} }
/** The ticket as the REST API returns it: read model, allowed transitions and what blocks it. */ /**
async issueDto(issue: IssueReadModel) { * The ticket as the REST API returns it: read model, allowed transitions,
* what blocks it and whether `viewerId` voted for it.
*/
async issueDto(issue: IssueReadModel, viewerId: string | null) {
const [allowedTransitions, blockedBy] = await Promise.all([ const [allowedTransitions, blockedBy] = await Promise.all([
this.workflows.allowedTransitions(issue), this.workflows.allowedTransitions(issue),
this.blockers.blockedBy(issue), this.blockers.blockedBy(issue),
]); ]);
// Who voted is only for telling someone whether they did. // Who voted is only for telling someone whether they did.
const { voterIds: _voters, ...shown } = issue; const { voterIds, ...shown } = issue;
return { ...shown, allowedTransitions, blockedBy }; return {
...shown,
allowedTransitions,
blockedBy,
voted: !!viewerId && voterIds.includes(viewerId),
};
} }
issueDtos(issues: IssueReadModel[]) { issueDtos(issues: IssueReadModel[], viewerId: string | null) {
return Promise.all(issues.map((issue) => this.issueDto(issue))); return Promise.all(issues.map((issue) => this.issueDto(issue, viewerId)));
} }
} }

View File

@@ -177,7 +177,10 @@ export class ReleasesController {
limit: page.limit, limit: page.limit,
offset: page.offset, offset: page.offset,
}); });
return { ...found, items: await this.refs.issueDtos(found.items) }; return {
...found,
items: await this.refs.issueDtos(found.items, viewerOf(me)),
};
} }
@Post('releases/:releaseId/publish') @Post('releases/:releaseId/publish')

View File

@@ -223,7 +223,10 @@ export class SprintsController {
limit: query.limit, limit: query.limit,
offset: query.offset, offset: query.offset,
}); });
return { ...page, items: await this.refs.issueDtos(page.items) }; return {
...page,
items: await this.refs.issueDtos(page.items, viewerOf(me)),
};
} }
@Put('issues/:issue/sprint') @Put('issues/:issue/sprint')

View File

@@ -1,12 +1,43 @@
/** Internal type. DO NOT USE DIRECTLY. */ /** Internal type. DO NOT USE DIRECTLY. */
type Exact<T extends { [key: string]: unknown }> = { [K in keyof T]: T[K] }; type Exact<T extends { [key: string]: unknown }> = { [K in keyof T]: T[K] };
/** Internal type. DO NOT USE DIRECTLY. */ /** Internal type. DO NOT USE DIRECTLY. */
export type Incremental<T> = T | { [P in keyof T]?: P extends ' $fragmentName' | '__typename' ? T[P] : never }; export type Incremental<T> =
| T
| {
[P in keyof T]?: P extends ' $fragmentName' | '__typename' ? T[P] : never;
};
import type { TypedDocumentNode as DocumentNode } from '@graphql-typed-document-node/core'; import type { TypedDocumentNode as DocumentNode } from '@graphql-typed-document-node/core';
export type MeQueryVariables = Exact<{ [key: string]: never; }>; export type MeQueryVariables = Exact<{ [key: string]: never }>;
export type MeQuery = {
me: { id: string; name: string; email: string; appRoles: Array<string> };
};
export type MeQuery = { me: { id: string, name: string, email: string, appRoles: Array<string> } }; export const MeDocument = {
kind: 'Document',
definitions: [
export const MeDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"Me"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"me"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"email"}},{"kind":"Field","name":{"kind":"Name","value":"appRoles"}}]}}]}}]} as unknown as DocumentNode<MeQuery, MeQueryVariables>; {
kind: 'OperationDefinition',
operation: 'query',
name: { kind: 'Name', value: 'Me' },
selectionSet: {
kind: 'SelectionSet',
selections: [
{
kind: 'Field',
name: { kind: 'Name', value: 'me' },
selectionSet: {
kind: 'SelectionSet',
selections: [
{ kind: 'Field', name: { kind: 'Name', value: 'id' } },
{ kind: 'Field', name: { kind: 'Name', value: 'name' } },
{ kind: 'Field', name: { kind: 'Name', value: 'email' } },
{ kind: 'Field', name: { kind: 'Name', value: 'appRoles' } },
],
},
},
],
},
},
],
} as unknown as DocumentNode<MeQuery, MeQueryVariables>;

View File

@@ -2,3 +2,4 @@ export * from './lib/api-config';
export * from './lib/access-token-source'; export * from './lib/access-token-source';
export * from './lib/graphql.provider'; export * from './lib/graphql.provider';
export * from './lib/graphql-errors'; export * from './lib/graphql-errors';
export * from './lib/realtime-connection';

View File

@@ -14,6 +14,7 @@ import { HttpLink } from 'apollo-angular/http';
import { createClient } from 'graphql-ws'; import { createClient } from 'graphql-ws';
import { AccessTokenSource } from './access-token-source'; import { AccessTokenSource } from './access-token-source';
import { API_CONFIG } from './api-config'; import { API_CONFIG } from './api-config';
import { RealtimeConnection } from './realtime-connection';
/** /**
* Apollo client for the task-stream API: HTTP for queries/mutations, * Apollo client for the task-stream API: HTTP for queries/mutations,
@@ -22,6 +23,10 @@ import { API_CONFIG } from './api-config';
* The cache is disabled on purpose: state lives in the NgRx signal stores, * The cache is disabled on purpose: state lives in the NgRx signal stores,
* which are fed by queries and kept fresh by subscriptions. * which are fed by queries and kept fresh by subscriptions.
*/ */
/** How often the socket is pinged, and how long a pong may take. */
const PING_EVERY_MS = 10_000;
const PONG_WITHIN_MS = 5_000;
export function provideGraphql(): EnvironmentProviders { export function provideGraphql(): EnvironmentProviders {
return makeEnvironmentProviders([ return makeEnvironmentProviders([
provideApollo(() => { provideApollo(() => {
@@ -50,16 +55,43 @@ export function provideGraphql(): EnvironmentProviders {
} }
}); });
const connection = inject(RealtimeConnection);
// A socket that goes quiet is cut by proxies (Traefik's idle timeout),
// and one that dies without a close frame is never noticed: subscribers
// just stop hearing. Pings keep it busy; a ping with no pong in time
// closes it, and the client reconnects (with a fresh token).
let socket: WebSocket | null = null;
let pongTimeout: ReturnType<typeof setTimeout> | undefined;
let connectedBefore = false;
const ws = new GraphQLWsLink( const ws = new GraphQLWsLink(
createClient({ createClient({
url: config.graphqlWsUrl, url: config.graphqlWsUrl,
lazy: true, lazy: true,
retryAttempts: Infinity, retryAttempts: Infinity,
shouldRetry: () => true, shouldRetry: () => true,
keepAlive: PING_EVERY_MS,
connectionParams: async () => { connectionParams: async () => {
const token = await tokens.accessToken(); const token = await tokens.accessToken();
return token ? { authorization: `Bearer ${token}` } : {}; return token ? { authorization: `Bearer ${token}` } : {};
}, },
on: {
connected: (opened) => {
socket = opened as WebSocket;
// What happened while it was down was lost: views reload.
if (connectedBefore) connection.reconnected();
connectedBefore = true;
},
ping: (received) => {
if (received) return;
pongTimeout = setTimeout(() => {
if (socket?.readyState === WebSocket.OPEN)
socket.close(4408, 'No pong');
}, PONG_WITHIN_MS);
},
pong: (received) => {
if (received) clearTimeout(pongTimeout);
},
},
}), }),
); );

View File

@@ -0,0 +1,20 @@
import { Injectable } from '@angular/core';
import { Observable, Subject } from 'rxjs';
/**
* The state of the subscriptions' socket that the app cares about: when it
* comes back after a cut. Whatever happened meanwhile was not received, so
* views reload when it does.
*/
@Injectable({ providedIn: 'root' })
export class RealtimeConnection {
private readonly back = new Subject<void>();
/** Each time the socket reconnects (not the first time it connects). */
readonly reconnected$: Observable<void> = this.back.asObservable();
/** Called by the GraphQL client. */
reconnected(): void {
this.back.next();
}
}

View File

@@ -41,6 +41,7 @@ const analytics = { from: 'a', to: 'b', qa: { failureRate: 0.25 } };
/** Every command succeeds, every query answers once, nothing arrives in real time. */ /** Every command succeeds, every query answers once, nothing arrives in real time. */
function gateways() { function gateways() {
const reconnected = new Subject<void>();
return { return {
projects: { projects: {
project: vi.fn(() => of(project)), project: vi.fn(() => of(project)),
@@ -92,7 +93,9 @@ function gateways() {
releaseChanges: () => NEVER, releaseChanges: () => NEVER,
sprintChanges: () => NEVER, sprintChanges: () => NEVER,
projectChanges: vi.fn((): unknown => NEVER), projectChanges: vi.fn((): unknown => NEVER),
reconnects: () => reconnected,
}, },
reconnected,
}; };
} }
@@ -161,6 +164,19 @@ describe('BoardFacade', () => {
expect(board.filter()).toEqual({ projectId: 'p2' }); expect(board.filter()).toEqual({ projectId: 'p2' });
}); });
it('reloads what it shows when the live connection comes back', () => {
const { board, fakes } = setup();
board.show({ projectId: 'p1', assigneeId: 'u2' });
fakes.issues.board.mockClear();
fakes.reconnected.next();
expect(fakes.issues.board).toHaveBeenCalledWith({
projectId: 'p1',
assigneeId: 'u2',
});
});
it('saves the current filter as a view and knows when it is showing one', () => { it('saves the current filter as a view and knows when it is showing one', () => {
const { board, fakes } = setup(); const { board, fakes } = setup();
const view = { const view = {
@@ -480,7 +496,7 @@ describe('InboxFacade', () => {
{ provide: NotificationsGateway, useValue: gateway }, { provide: NotificationsGateway, useValue: gateway },
{ {
provide: TrackerRealtimeGateway, provide: TrackerRealtimeGateway,
useValue: { notifications: () => arrivals }, useValue: { notifications: () => arrivals, reconnects: () => NEVER },
}, },
{ {
provide: Notifications, provide: Notifications,

View File

@@ -79,6 +79,7 @@ export const BacklogStore = signalStore(
switchMap(({ payload }) => switchMap(({ payload }) =>
merge( merge(
realtime.issueChanges(payload.projectId).pipe(debounceTime(400)), realtime.issueChanges(payload.projectId).pipe(debounceTime(400)),
realtime.reconnects(),
events.on(backlogApiEvents.moveFailed, backlogApiEvents.bulkDone), events.on(backlogApiEvents.moveFailed, backlogApiEvents.bulkDone),
).pipe( ).pipe(
startWith(null), startWith(null),

View File

@@ -20,6 +20,7 @@ import {
distinctUntilChanged, distinctUntilChanged,
EMPTY, EMPTY,
exhaustMap, exhaustMap,
filter,
map, map,
merge, merge,
mergeMap, mergeMap,
@@ -112,6 +113,12 @@ export const BoardStore = signalStore(
load$: events load$: events
.on(boardPageEvents.opened, boardPageEvents.filterChanged) .on(boardPageEvents.opened, boardPageEvents.filterChanged)
.pipe(switchMap(({ payload }) => load(payload))), .pipe(switchMap(({ payload }) => load(payload))),
// Back after a cut: what moved meanwhile was not received.
resync$: realtime.reconnects().pipe(
map(() => store.filter()),
filter((current): current is BoardFilter => !!current),
switchMap((current) => load(current)),
),
live$: events.on(boardPageEvents.opened).pipe( live$: events.on(boardPageEvents.opened).pipe(
map(({ payload }) => payload.projectId), map(({ payload }) => payload.projectId),
distinctUntilChanged(), distinctUntilChanged(),

View File

@@ -49,6 +49,7 @@ export const EpicsStore = signalStore(
switchMap(({ payload }) => switchMap(({ payload }) =>
merge( merge(
realtime.issueChanges(payload.projectId), realtime.issueChanges(payload.projectId),
realtime.reconnects(),
// One's own vote, without waiting for the live update. // One's own vote, without waiting for the live update.
events.on(epicsApiEvents.voted), events.on(epicsApiEvents.voted),
).pipe( ).pipe(

View File

@@ -85,7 +85,11 @@ export const InboxStore = signalStore(
) => { ) => {
const closed$ = events.on(inboxPageEvents.closed); const closed$ = events.on(inboxPageEvents.closed);
return { return {
load$: events.on(inboxPageEvents.opened).pipe( // Also when the live connection comes back: what arrived meanwhile was missed.
load$: merge(
events.on(inboxPageEvents.opened),
realtime.reconnects(),
).pipe(
switchMap(() => switchMap(() =>
gateway.mine(false, LIMIT).pipe( gateway.mine(false, LIMIT).pipe(
mapResponse({ mapResponse({

View File

@@ -243,13 +243,15 @@ export const IssueStore = signalStore(
* API projects its read model a moment after answering, so the * API projects its read model a moment after answering, so the
* ticket, its comments and its subtasks are asked for twice. * ticket, its comments and its subtasks are asked for twice.
*/ */
catchUp$: events catchUp$: merge(
.on( events.on(
issueApiEvents.commandSucceeded, issueApiEvents.commandSucceeded,
issueApiEvents.checklistSaved, issueApiEvents.checklistSaved,
issuePageEvents.imagePasted, issuePageEvents.imagePasted,
) ),
.pipe( // Back after a cut: comments or changes may have been missed.
realtime.reconnects(),
).pipe(
map(() => store.issue()), map(() => store.issue()),
filter((issue): issue is Issue => !!issue), filter((issue): issue is Issue => !!issue),
switchMap((issue) => switchMap((issue) =>

View File

@@ -100,6 +100,7 @@ export const ListStore = signalStore(
), ),
merge( merge(
realtime.issueChanges(payload.projectId), realtime.issueChanges(payload.projectId),
realtime.reconnects(),
events.on( events.on(
listApiEvents.changed, listApiEvents.changed,
listApiEvents.changeFailed, listApiEvents.changeFailed,

View File

@@ -242,6 +242,20 @@ export const ProjectStore = signalStore(
), ),
), ),
), ),
// Back after a cut: refetch it, and its details follow `loaded`.
resync$: realtime.reconnects().pipe(
map(() => store.project()?.key),
filter((key): key is string => !!key),
switchMap((key) =>
projects.project(key).pipe(
mapResponse({
next: (project) => projectApiEvents.loaded(project),
error: (error) =>
projectApiEvents.loadFailed(describeError(error)),
}),
),
),
),
details$: loaded$.pipe( details$: loaded$.pipe(
switchMap(({ payload }) => switchMap(({ payload }) =>
merge( merge(

View File

@@ -14,7 +14,7 @@ import {
ProjectsGateway, ProjectsGateway,
TrackerRealtimeGateway, TrackerRealtimeGateway,
} from '@task-stream/web/tracker/domain'; } from '@task-stream/web/tracker/domain';
import { exhaustMap, map, switchMap, tap } from 'rxjs'; import { exhaustMap, map, merge, switchMap, tap } from 'rxjs';
import { import {
projectsApiEvents, projectsApiEvents,
projectsPageEvents, projectsPageEvents,
@@ -74,7 +74,11 @@ export const ProjectsStore = signalStore(
router = inject(Router), router = inject(Router),
auth = inject(AuthFacade), auth = inject(AuthFacade),
) => ({ ) => ({
load$: events.on(projectsPageEvents.opened).pipe( load$: merge(
events.on(projectsPageEvents.opened),
// Back after a cut: being added or removed meanwhile was missed.
realtime.reconnects(),
).pipe(
switchMap(() => switchMap(() =>
projects.myProjects().pipe( projects.myProjects().pipe(
mapResponse({ mapResponse({

View File

@@ -54,6 +54,7 @@ export enum NotificationKind {
COMMENTED = 'COMMENTED', COMMENTED = 'COMMENTED',
MENTIONED = 'MENTIONED', MENTIONED = 'MENTIONED',
REPLIED = 'REPLIED', REPLIED = 'REPLIED',
PROPOSED = 'PROPOSED',
WATCHED = 'WATCHED', WATCHED = 'WATCHED',
ADDED_TO_PROJECT = 'ADDED_TO_PROJECT', ADDED_TO_PROJECT = 'ADDED_TO_PROJECT',
REMINDER = 'REMINDER', REMINDER = 'REMINDER',

View File

@@ -370,4 +370,9 @@ export abstract class TrackerRealtimeGateway {
abstract projectChanges(projectId: string): Observable<ProjectChanged>; abstract projectChanges(projectId: string): Observable<ProjectChanged>;
abstract myProjectsChanges(): Observable<ProjectChanged>; abstract myProjectsChanges(): Observable<ProjectChanged>;
abstract notifications(): Observable<Notification>; abstract notifications(): Observable<Notification>;
/**
* Each time the live connection comes back after a cut: what happened
* meanwhile was not received, so views reload.
*/
abstract reconnects(): Observable<void>;
} }

View File

@@ -1,5 +1,8 @@
import { inject, Injectable } from '@angular/core'; import { inject, Injectable } from '@angular/core';
import { AccessTokenSource } from '@task-stream/web/shared/infrastructure'; import {
AccessTokenSource,
RealtimeConnection,
} from '@task-stream/web/shared/infrastructure';
import { import {
Activity, Activity,
CommentChanged, CommentChanged,
@@ -38,6 +41,11 @@ import {
export class GraphqlRealtimeGateway extends TrackerRealtimeGateway { export class GraphqlRealtimeGateway extends TrackerRealtimeGateway {
private readonly gql = inject(GraphqlOperations); private readonly gql = inject(GraphqlOperations);
private readonly tokens = inject(AccessTokenSource); private readonly tokens = inject(AccessTokenSource);
private readonly connection = inject(RealtimeConnection);
reconnects(): Observable<void> {
return this.connection.reconnected$;
}
private signedIn<T>(stream: () => Observable<T>): Observable<T> { private signedIn<T>(stream: () => Observable<T>): Observable<T> {
return defer(() => this.tokens.accessToken()).pipe( return defer(() => this.tokens.accessToken()).pipe(

View File

@@ -212,6 +212,7 @@ export type NotificationKind =
| 'ASSIGNED' | 'ASSIGNED'
| 'COMMENTED' | 'COMMENTED'
| 'MENTIONED' | 'MENTIONED'
| 'PROPOSED'
| 'QA_FAILED' | 'QA_FAILED'
| 'REMINDER' | 'REMINDER'
| 'REPLIED' | 'REPLIED'

View File

@@ -772,6 +772,9 @@ export class BoardPage {
event: CdkDragDrop<BoardColumn, BoardColumn, IssueSummary>, event: CdkDragDrop<BoardColumn, BoardColumn, IssueSummary>,
): void { ): void {
if (event.previousContainer === event.container) return; if (event.previousContainer === event.container) return;
// Let go over a column it may not enter: the CDK leaves it in the last
// one it could, which is not where the person aimed. Back where it was.
if (!event.isPointerOverContainer) return;
const to = event.container.data.status; const to = event.container.data.status;
const issueId = event.item.data.id; const issueId = event.item.data.id;
const target = this.board.columns().find((c) => c.status === to); const target = this.board.columns().find((c) => c.status === to);

View File

@@ -562,102 +562,6 @@ a.ts-key {
align-items: center; align-items: center;
} }
// Waiting for other tickets: shown next to the key, with links to them.
.ts-blocked {
display: inline-flex;
flex-wrap: wrap;
gap: 0.375rem;
align-items: center;
padding: 0.125rem 0.5rem;
border-radius: 999px;
background: color-mix(in srgb, var(--ts-c-red, #d33) 12%, transparent);
color: var(--ts-c-red, var(--ts-ink));
font-size: 0.8125rem;
font-weight: 600;
.ts-key {
color: inherit;
}
}
// 👍 2 · 🎉 1 · (+)
.ts-reactions {
display: flex;
flex-wrap: wrap;
gap: 0.375rem;
align-items: center;
margin-top: 0.5rem;
}
.ts-reaction {
display: inline-flex;
gap: 0.25rem;
align-items: center;
// @pi/ui styles every <button> too specifically to override otherwise.
min-width: 0 !important;
height: auto !important;
padding: 0.125rem 0.5rem !important;
border: 1px solid var(--ts-line) !important;
border-radius: 999px !important;
background: var(--ts-surface);
color: inherit;
font: inherit;
font-size: 0.875rem;
cursor: pointer;
.ts-mono {
font-size: 0.75rem;
}
&.is-mine {
border-color: var(--ts-brand) !important;
background: color-mix(in srgb, var(--ts-brand) 12%, var(--ts-surface));
}
&:disabled {
cursor: default;
}
}
.ts-reaction-picker {
position: relative;
summary {
display: inline-grid;
place-items: center;
width: 1.75rem;
height: 1.625rem;
border: 1px dashed var(--ts-line-strong, var(--ts-line));
border-radius: 999px;
color: var(--ts-muted);
cursor: pointer;
list-style: none;
&::-webkit-details-marker {
display: none;
}
}
}
.ts-reaction-picker__menu {
position: absolute;
z-index: 5;
bottom: calc(100% + 0.375rem);
left: 0;
display: flex;
gap: 0.125rem;
padding: 0.25rem;
border: 1px solid var(--ts-line);
border-radius: 0.75rem;
background: var(--ts-surface);
box-shadow: 0 8px 24px rgb(0 0 0 / 14%);
button {
width: 2rem;
min-width: 0 !important;
height: 2rem;
padding: 0 !important;
border: 0 !important;
border-radius: 0.5rem !important;
background: none;
font-size: 1.125rem;
cursor: pointer;
&:hover {
background: color-mix(in srgb, var(--ts-brand) 12%, transparent);
}
}
}
.ts-votes { .ts-votes {
padding: 0.25rem 0.625rem; padding: 0.25rem 0.625rem;
border: 1px solid var(--ts-line); border: 1px solid var(--ts-line);

View File

@@ -49,7 +49,6 @@ import {
mentionQuery, mentionQuery,
POINT_SCALE, POINT_SCALE,
ReleaseStatus, ReleaseStatus,
REACTIONS,
} from '@task-stream/web/tracker/domain'; } from '@task-stream/web/tracker/domain';
import { import {
clock, clock,
@@ -63,6 +62,8 @@ import {
typeLook, typeLook,
} from '../ui/copy'; } from '../ui/copy';
import { AttachmentItem } from '../ui/attachments'; import { AttachmentItem } from '../ui/attachments';
import { BlockedComponent } from '../ui/blocked';
import { ReactionsComponent } from '../ui/reactions';
import { import {
AttachmentLoader, AttachmentLoader,
ImageUploader, ImageUploader,
@@ -79,6 +80,8 @@ const MAX_FILE_BYTES = 25 * 1024 * 1024;
selector: 'ts-issue-page', selector: 'ts-issue-page',
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
imports: [ imports: [
BlockedComponent,
ReactionsComponent,
MarkdownComponent, MarkdownComponent,
MarkdownHintComponent, MarkdownHintComponent,
PasteImagesDirective, PasteImagesDirective,
@@ -144,19 +147,7 @@ const MAX_FILE_BYTES = 25 * 1024 * 1024;
</a> </a>
} }
@if (issue.blockedBy.length) { @if (issue.blockedBy.length) {
<span <ts-blocked [blockers]="issue.blockedBy" />
class="ts-blocked"
role="status"
[title]="blockedTitle(issue.blockedBy)"
>
<pi-icon icon="lock" size="x-small" aria-hidden="true" />
Bloqueado por
@for (blocker of issue.blockedBy; track blocker.id) {
<a class="ts-key" [routerLink]="['..', blocker.key]">{{
blocker.key
}}</a>
}
</span>
} }
</div> </div>
<div class="ts-issue__title-row"> <div class="ts-issue__title-row">
@@ -634,50 +625,14 @@ const MAX_FILE_BYTES = 25 * 1024 * 1024;
!comment.deleted && !comment.deleted &&
(comment.reactions.length || project.myRole()) (comment.reactions.length || project.myRole())
) { ) {
<div class="ts-reactions"> <ts-reactions
@for ( [reactions]="comment.reactions"
reaction of comment.reactions; [me]="facade.me()"
track reaction.emoji [canReact]="!!project.myRole()"
) { (react)="
<button facade.react(comment.id, $event.emoji, $event.on)
type="button"
class="ts-reaction"
[class.is-mine]="reacted(reaction)"
[attr.aria-pressed]="reacted(reaction)"
[title]="reactionTitle(reaction)"
[disabled]="!project.myRole()"
(click)="
facade.react(
comment.id,
reaction.emoji,
!reacted(reaction)
)
" "
> />
{{ reaction.emoji }}
<span class="ts-mono">{{
reaction.users.length
}}</span>
</button>
}
@if (project.myRole()) {
<details class="ts-reaction-picker">
<summary title="Reaccionar" aria-label="Reaccionar">
<pi-icon icon="add" size="x-small" />
</summary>
<div class="ts-reaction-picker__menu">
@for (emoji of reactions; track emoji) {
<button
type="button"
(click)="pickReaction(comment, emoji, $event)"
>
{{ emoji }}
</button>
}
</div>
</details>
}
</div>
} }
@if (!comment.deleted && filesOf(comment).length) { @if (!comment.deleted && filesOf(comment).length) {
<div class="ts-attachments"> <div class="ts-attachments">
@@ -1194,30 +1149,6 @@ export class IssuePage {
() => this.facade.comments().filter((c) => !c.deleted).length, () => this.facade.comments().filter((c) => !c.deleted).length,
); );
protected readonly commentFiles = signal<File[]>([]); protected readonly commentFiles = signal<File[]>([]);
protected readonly reactions = REACTIONS;
protected reacted(reaction: { users: { id: string }[] }): boolean {
return reaction.users.some((u) => u.id === this.facade.me());
}
protected reactionTitle(reaction: {
emoji: string;
users: { name: string }[];
}): string {
return `${reaction.users.map((u) => u.name).join(', ')} · ${reaction.emoji}`;
}
/** From the picker: adds it, or takes it back if it was already there. */
protected pickReaction(comment: Comment, emoji: string, event: Event): void {
const mine = comment.reactions.find((r) => r.emoji === emoji);
this.facade.react(comment.id, emoji, !(mine && this.reacted(mine)));
(event.target as HTMLElement).closest('details')?.removeAttribute('open');
}
protected blockedTitle(blockers: { key: string; title: string }[]): string {
return blockers.map((b) => `${b.key}: ${b.title}`).join('\n');
}
protected duplicate(): void { protected duplicate(): void {
this.facade.duplicate().subscribe({ this.facade.duplicate().subscribe({
next: (key) => { next: (key) => {
@@ -1285,8 +1216,24 @@ export class IssuePage {
protected readonly maxFileBytes = MAX_FILE_BYTES; protected readonly maxFileBytes = MAX_FILE_BYTES;
/** Files on the ticket itself; those under a comment are shown there. */ /** Files on the ticket itself; those under a comment are shown there. */
/** Files shown inside the description or a comment (pasted images). */
private readonly inlineFiles = computed(() => {
const texts = [
this.facade.issue()?.description ?? '',
...this.facade.comments().map((c) => c.body),
].join('\n');
return new Set(
[...texts.matchAll(/\(attachment:([\w-]{1,64})\)/g)].map((m) => m[1]),
);
});
/** The ticket's own files, not the ones already shown inside a text. */
protected readonly ticketFiles = computed( protected readonly ticketFiles = computed(
() => this.facade.issue()?.attachments.filter((a) => !a.commentId) ?? [], () =>
this.facade
.issue()
?.attachments.filter(
(a) => !a.commentId && !this.inlineFiles().has(a.id),
) ?? [],
); );
protected filesOf = (comment: Comment): Attachment[] => protected filesOf = (comment: Comment): Attachment[] =>
this.facade this.facade

View File

@@ -29,6 +29,7 @@ import {
IssueSummary, IssueSummary,
StatusCategory, StatusCategory,
} from '@task-stream/web/tracker/domain'; } from '@task-stream/web/tracker/domain';
import { BlockedComponent } from '../ui/blocked';
import { BulkBarComponent } from '../ui/bulk-bar'; import { BulkBarComponent } from '../ui/bulk-bar';
import { VoteComponent } from '../ui/vote'; import { VoteComponent } from '../ui/vote';
import { import {
@@ -77,6 +78,7 @@ const numberOf = (key: string) => Number(key.split('-').pop());
selector: 'ts-list-page', selector: 'ts-list-page',
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
imports: [ imports: [
BlockedComponent,
BulkBarComponent, BulkBarComponent,
VoteComponent, VoteComponent,
RouterLink, RouterLink,
@@ -183,12 +185,11 @@ const numberOf = (key: string) => Number(key.split('-').pop());
issue.title issue.title
}}</a> }}</a>
@if (issue.blockedBy.length) { @if (issue.blockedBy.length) {
<span <ts-blocked
class="ts-list__blocked" class="ts-list__blocked"
[title]="'Bloqueado por ' + blockersOf(issue)" [blockers]="issue.blockedBy"
> [compact]="true"
<pi-icon icon="lock" size="x-small" /> Bloqueado />
</span>
} }
@if (issue.parent; as parent) { @if (issue.parent; as parent) {
<span class="ts-muted ts-mono" [title]="parent.title"> <span class="ts-muted ts-mono" [title]="parent.title">
@@ -388,16 +389,7 @@ const numberOf = (key: string) => Number(key.split('-').pop());
} }
} }
.ts-list__blocked { .ts-list__blocked {
display: inline-flex;
gap: 0.25rem;
align-items: center;
margin-left: 0.5rem; margin-left: 0.5rem;
padding: 0 0.375rem;
border-radius: 999px;
background: color-mix(in srgb, var(--ts-c-red, #d33) 12%, transparent);
color: var(--ts-c-red, var(--ts-ink));
font-size: 0.75rem;
font-weight: 600;
} }
.ts-list__type-label { .ts-list__type-label {
display: inline-flex; display: inline-flex;
@@ -644,10 +636,6 @@ export class ListPage {
this.facade.assign(issue.id, assigneeId); this.facade.assign(issue.id, assigneeId);
} }
protected blockersOf(issue: IssueSummary): string {
return issue.blockedBy.map((b) => b.key).join(', ');
}
protected isLate(issue: IssueSummary): boolean { protected isLate(issue: IssueSummary): boolean {
return ( return (
!!issue.dueDate && !!issue.dueDate &&

View File

@@ -0,0 +1,54 @@
import { ChangeDetectionStrategy, Component, input } from '@angular/core';
import { RouterLink } from '@angular/router';
import { PiIconComponent } from '@pi/ui';
import { IssueParent } from '@task-stream/web/tracker/domain';
/**
* 🔒 «Bloqueado»: the ticket waits for others still open. `compact` for
* cards and rows (the keys go in the tooltip); in full, they link.
*/
@Component({
selector: 'ts-blocked',
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [PiIconComponent, RouterLink],
host: { role: 'status', '[title]': 'tooltip()' },
template: `
<pi-icon icon="lock" size="x-small" aria-hidden="true" />
@if (compact()) {
Bloqueado
} @else {
Bloqueado por
@for (blocker of blockers(); track blocker.id) {
<a class="ts-key" [routerLink]="['..', blocker.key]">{{
blocker.key
}}</a>
}
}
`,
styles: `
:host {
display: inline-flex;
flex-wrap: wrap;
gap: 0.25rem 0.375rem;
align-items: center;
padding: 0.0625rem 0.5rem;
border-radius: 999px;
background: color-mix(in srgb, var(--ts-c-red, #d33) 12%, transparent);
color: var(--ts-c-red, var(--ts-ink));
font-size: 0.75rem;
font-weight: 600;
}
.ts-key {
color: inherit;
}
`,
})
export class BlockedComponent {
readonly blockers = input.required<IssueParent[]>();
readonly compact = input(false);
protected tooltip(): string {
return this.blockers()
.map((b) => `${b.key}: ${b.title}`)
.join('\n');
}
}

View File

@@ -1,3 +1,4 @@
import { BlockedComponent } from './blocked';
import { import {
ChangeDetectionStrategy, ChangeDetectionStrategy,
Component, Component,
@@ -18,7 +19,7 @@ import { initials, PRIORITY, PRIORITY_LEVEL, typeLook } from './copy';
@Component({ @Component({
selector: 'ts-issue-card', selector: 'ts-issue-card',
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
imports: [RouterLink, PiIconComponent], imports: [RouterLink, PiIconComponent, BlockedComponent],
template: ` template: `
<a <a
class="ts-card" class="ts-card"
@@ -32,15 +33,7 @@ import { initials, PRIORITY, PRIORITY_LEVEL, typeLook } from './copy';
</span> </span>
<span class="ts-card__key">{{ issue().key }}</span> <span class="ts-card__key">{{ issue().key }}</span>
@if (issue().blockedBy.length) { @if (issue().blockedBy.length) {
<span <ts-blocked [blockers]="issue().blockedBy" [compact]="true" />
class="ts-card__blocked"
[attr.title]="
'Bloqueado por ' + issue().blockedBy.map(blockerKey).join(', ')
"
>
<pi-icon icon="lock" size="x-small" />
Bloqueado
</span>
} }
@if (issue().area; as area) { @if (issue().area; as area) {
<span class="ts-card__area">{{ areaLabel(area) }}</span> <span class="ts-card__area">{{ areaLabel(area) }}</span>
@@ -191,17 +184,6 @@ import { initials, PRIORITY, PRIORITY_LEVEL, typeLook } from './copy';
font-weight: 600; font-weight: 600;
color: var(--ts-ink-soft); color: var(--ts-ink-soft);
} }
.ts-card__blocked {
display: inline-flex;
gap: 0.25rem;
align-items: center;
padding: 0 0.375rem;
border-radius: 999px;
background: color-mix(in srgb, var(--ts-c-red, #d33) 12%, transparent);
color: var(--ts-c-red, var(--ts-ink));
font-size: 0.6875rem;
font-weight: 600;
}
.ts-card__area { .ts-card__area {
margin-left: auto; margin-left: auto;
font-family: var(--ts-font-mono); font-family: var(--ts-font-mono);
@@ -281,8 +263,6 @@ import { initials, PRIORITY, PRIORITY_LEVEL, typeLook } from './copy';
`, `,
}) })
export class IssueCardComponent { export class IssueCardComponent {
protected readonly blockerKey = (blocker: { key: string }) => blocker.key;
/** Late, due today or in the next two days. */ /** Late, due today or in the next two days. */
protected readonly due = computed(() => protected readonly due = computed(() =>
dueState(this.issue().dueDate, this.issue().statusCategory), dueState(this.issue().dueDate, this.issue().statusCategory),

View File

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

View File

@@ -0,0 +1,150 @@
import {
ChangeDetectionStrategy,
Component,
input,
output,
} from '@angular/core';
import { PiIconComponent } from '@pi/ui';
import { REACTIONS, UserRef } from '@task-stream/web/tracker/domain';
/**
* 👍 2 · 🎉 1 · (+) under a comment: a click adds or takes back one's own;
* the (+) offers the rest. Read-only for whoever cannot react.
*/
@Component({
selector: 'ts-reactions',
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [PiIconComponent],
template: `
@for (reaction of reactions(); track reaction.emoji) {
<button
type="button"
class="ts-reaction"
[class.is-mine]="mine(reaction)"
[attr.aria-pressed]="mine(reaction)"
[title]="title(reaction)"
[disabled]="!canReact()"
(click)="react.emit({ emoji: reaction.emoji, on: !mine(reaction) })"
>
{{ reaction.emoji }}
<span class="ts-mono">{{ reaction.users.length }}</span>
</button>
}
@if (canReact()) {
<details class="ts-reaction-picker">
<summary title="Reaccionar" aria-label="Reaccionar">
<pi-icon icon="add" size="x-small" />
</summary>
<div class="ts-reaction-picker__menu">
@for (emoji of offered; track emoji) {
<button type="button" (click)="pick(emoji, $event)">
{{ emoji }}
</button>
}
</div>
</details>
}
`,
styles: `
:host {
display: flex;
flex-wrap: wrap;
gap: 0.375rem;
align-items: center;
margin-top: 0.5rem;
}
/* @pi/ui styles every <button> too specifically to override without !important. */
.ts-reaction {
display: inline-flex;
gap: 0.25rem;
align-items: center;
min-width: 0 !important;
height: auto !important;
padding: 0.125rem 0.5rem !important;
border: 1px solid var(--ts-line) !important;
border-radius: 999px !important;
background: var(--ts-surface);
color: inherit;
font: inherit;
font-size: 0.875rem;
cursor: pointer;
.ts-mono {
font-size: 0.75rem;
}
&.is-mine {
border-color: var(--ts-brand) !important;
background: color-mix(in srgb, var(--ts-brand) 12%, var(--ts-surface));
}
&:disabled {
cursor: default;
}
}
.ts-reaction-picker {
position: relative;
summary {
display: inline-grid;
place-items: center;
width: 1.75rem;
height: 1.625rem;
border: 1px dashed var(--ts-line-strong, var(--ts-line));
border-radius: 999px;
color: var(--ts-muted);
cursor: pointer;
list-style: none;
&::-webkit-details-marker {
display: none;
}
}
}
.ts-reaction-picker__menu {
position: absolute;
z-index: 5;
bottom: calc(100% + 0.375rem);
left: 0;
display: flex;
gap: 0.125rem;
padding: 0.25rem;
border: 1px solid var(--ts-line);
border-radius: 0.75rem;
background: var(--ts-surface);
box-shadow: 0 8px 24px rgb(0 0 0 / 14%);
button {
width: 2rem;
min-width: 0 !important;
height: 2rem;
padding: 0 !important;
border: 0 !important;
border-radius: 0.5rem !important;
background: none;
font-size: 1.125rem;
cursor: pointer;
&:hover {
background: color-mix(in srgb, var(--ts-brand) 12%, transparent);
}
}
}
`,
})
export class ReactionsComponent {
readonly reactions = input.required<{ emoji: string; users: UserRef[] }[]>();
/** Signed-in user's id. */
readonly me = input<string | null>(null);
readonly canReact = input(false);
readonly react = output<{ emoji: string; on: boolean }>();
protected readonly offered = REACTIONS;
protected mine(reaction: { users: UserRef[] }): boolean {
return reaction.users.some((u) => u.id === this.me());
}
protected title(reaction: { emoji: string; users: UserRef[] }): string {
return `${reaction.users.map((u) => u.name).join(', ')} · ${reaction.emoji}`;
}
/** From the picker: adds it, or takes it back if it was already there. */
protected pick(emoji: string, event: Event): void {
const current = this.reactions().find((r) => r.emoji === emoji);
this.react.emit({ emoji, on: !(current && this.mine(current)) });
(event.target as HTMLElement).closest('details')?.removeAttribute('open');
}
}