diff --git a/apps/api/openapi.json b/apps/api/openapi.json index 3fd7781..721118c 100644 --- a/apps/api/openapi.json +++ b/apps/api/openapi.json @@ -10997,6 +10997,10 @@ "type": "number", "description": "How many want it done" }, + "voted": { + "type": "boolean", + "description": "Whether I voted for it (false without a token)" + }, "rank": { "type": "string", "description": "Place in the backlog and its board column (ascending, as text)" @@ -11064,6 +11068,7 @@ "parent", "blockedBy", "votes", + "voted", "rank", "checklist", "trashedAt", @@ -12673,6 +12678,7 @@ "COMMENTED", "MENTIONED", "REPLIED", + "PROPOSED", "WATCHED", "ADDED_TO_PROJECT", "REMINDER" diff --git a/apps/api/schema.gql b/apps/api/schema.gql index d97b4c4..500b743 100644 --- a/apps/api/schema.gql +++ b/apps/api/schema.gql @@ -824,6 +824,7 @@ enum NotificationKind { ASSIGNED COMMENTED MENTIONED + PROPOSED QA_FAILED REMINDER REPLIED diff --git a/apps/mcp/src/app/api/taskstream.schema.ts b/apps/mcp/src/app/api/taskstream.schema.ts index 603907d..5e09875 100644 --- a/apps/mcp/src/app/api/taskstream.schema.ts +++ b/apps/mcp/src/app/api/taskstream.schema.ts @@ -1875,6 +1875,8 @@ export interface components { blockedBy: components["schemas"]["IssueParentDto"][]; /** @description How many want it done */ 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) */ rank: string; checklist: components["schemas"]["ChecklistItemDto"][]; @@ -2438,7 +2440,7 @@ export interface components { path: string | null; }; /** @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: { id: string; kind: components["schemas"]["NotificationKind"]; diff --git a/docs/03-api.md b/docs/03-api.md index b7328aa..2c8b136 100644 --- a/docs/03-api.md +++ b/docs/03-api.md @@ -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, 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 -envía como mucho 5 cada 15 minutos (429); el límite vive en memoria de cada instancia, así que con varias es -aproximado. La web tiene el formulario en `/p/{CLAVE}/proponer`, enlazado en la cabecera del proyecto. +envía como mucho 5 cada 15 minutos (429); el contador vive en Redis (`RateLimitStore`), así que vale igual con +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 -(`voteIssue(issueId, on)`, `PUT/DELETE /api/issues/{issue}/vote`). Cada ticket da `votes` y, en GraphQL, `voted` (si he -votado yo); la lista de quién votó no se publica. +(`voteIssue(issueId, on)`, `PUT/DELETE /api/issues/{issue}/vote`). Cada ticket da `votes` y `voted` (si he votado +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 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 responde a un comentario | Quien escribió el comentario | `REPLIED` | | 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 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 diff --git a/docs/04-cliente-web.md b/docs/04-cliente-web.md index ea6d5a5..d1053e1 100644 --- a/docs/04-cliente-web.md +++ b/docs/04-cliente-web.md @@ -141,6 +141,10 @@ de identidad (`SessionTokenSource`) le pide el token para el cliente GraphQL. fuente de verdad. - Las subscriptions van por `graphql-ws` con el token en `connectionParams`; si la API responde `UNAUTHENTICATED`, la 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: ```sh diff --git a/docs/10-futuro.md b/docs/10-futuro.md index 8176292..a1ddf5d 100644 --- a/docs/10-futuro.md +++ b/docs/10-futuro.md @@ -6,34 +6,26 @@ cuando hay pistas, por dónde empezar. ## 10.1 Para dar el producto por terminado -| 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 | -| 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** | -| 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) | -| Registrar el webhook del cliente de contraseña en Pireon | Sin él, revocar un token de quien entra con contraseña no se nota hasta que caduca la caché | Solo si `PIREON_PASSWORD_CLIENT_ID` tiene valor: webhook en ese cliente y su secret en `PIREON_PASSWORD_WEBHOOK_SECRET` (ver [Autenticación](05-autenticacion.md)) | -| Una versión 1.0 | Un punto fijo al que volver | Desplegar todo, etiquetar `v1.0.0` y redactar la lista de cambios (las notas de versión de TaskStream dan el borrador) | -| Revisión de seguridad | Hay rutas sin sesión (proyectos públicos, escaparate, propuestas) y un MCP abierto a Internet | Permisos por rol, qué ve un anónimo, límites de peticiones (solo las propuestas tienen), CORS, cabeceras | -| Registro de errores en producción | Hoy un fallo solo se conoce si alguien avisa | Sentry (o similar) en la API, la web y el MCP | +| Qué | Por qué | Por dónde empezar | +| ---------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| 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` | +| 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) | +| Registrar el webhook del cliente de contraseña en Pireon | Sin él, revocar un token de quien entra con contraseña no se nota hasta que caduca la caché | Solo si `PIREON_PASSWORD_CLIENT_ID` tiene valor: webhook en ese cliente y su secret en `PIREON_PASSWORD_WEBHOOK_SECRET` (ver [Autenticación](05-autenticacion.md)) | +| Una versión 1.0 | Un punto fijo al que volver | Desplegar todo, etiquetar `v1.0.0` y redactar la lista de cambios (las notas de versión de TaskStream dan el borrador) | +| Revisión de seguridad | Hay rutas sin sesión (proyectos públicos, escaparate, propuestas) y un MCP abierto a Internet | Permisos por rol, qué ve un anónimo, límites de peticiones (solo las propuestas tienen), CORS, cabeceras | +| Registro de errores en producción | Hoy un fallo solo se conoce si alguien avisa | Sentry (o similar) en la API, la web y el MCP | ## 10.2 Fallos y deuda conocidos - **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. -- **Arrastrar a una columna prohibida**: la tarjeta se queda en la última columna permitida por la que pasó el cursor - (comportamiento de `@angular/cdk/drag-drop`). Debería volver a su sitio. -- **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 - (`issue.page.scss`) pasan de los 8 kB. Mirar qué entra en el paquete inicial (`marked` y `dompurify` deberían ir solo - 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). +- **Tamaño de la web**: el paquete inicial supera en ~130 kB el aviso de 2 MB. La causa es `@pi/ui`: su paquete + (un solo `fesm`) importa `chart.js` al cargar, aunque solo lo usan las gráficas de analítica. Se arregla en la + librería, con un punto de entrada aparte para las gráficas (`@pi/ui/charts`) o cargando `chart.js` bajo demanda. +- **Imágenes pegadas**: ya no salen en la lista de adjuntos, pero si se borran del texto el adjunto sigue guardado. - **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 renovarlo a mano cuando caduca. @@ -45,16 +37,14 @@ En orden de lo que más aportaría: - 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; - 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. - 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 +2. **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 +3. **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 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 +4. **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. -6. **Proyectos de la landing que ya existen**: hoy el tablero en TaskStream solo se abre al crear el proyecto; poder +5. **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 @@ -64,3 +54,14 @@ Mucho trabajo para lo que aportan hoy; se pueden recuperar si alguien los necesi - **Registro de horas** dedicadas a cada ticket. - **Objetivos u OKR** por encima de las épicas. - **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. diff --git a/docs/TaskStream-Documentacion.docx b/docs/TaskStream-Documentacion.docx index edf8f8e..f878685 100644 Binary files a/docs/TaskStream-Documentacion.docx and b/docs/TaskStream-Documentacion.docx differ diff --git a/docs/TaskStream-Documentacion.pdf b/docs/TaskStream-Documentacion.pdf index 3856ac4..244a6b0 100644 Binary files a/docs/TaskStream-Documentacion.pdf and b/docs/TaskStream-Documentacion.pdf differ diff --git a/libs/api/tracker/application/src/lib/automations/automation-rules.ts b/libs/api/tracker/application/src/lib/automations/automation-rules.ts index 2016bff..1d502aa 100644 --- a/libs/api/tracker/application/src/lib/automations/automation-rules.ts +++ b/libs/api/tracker/application/src/lib/automations/automation-rules.ts @@ -177,9 +177,10 @@ export function normalizeRule( }, action: { kind, + // `check` passed, so there is a value whenever one is needed. value: check ? kind === AutomationActionKind.ADD_LABEL - ? value!.trim().toLowerCase() + ? (value ?? '').trim().toLowerCase() : value : null, }, diff --git a/libs/api/tracker/application/src/lib/event-handlers/project-deleted.event-handler.ts b/libs/api/tracker/application/src/lib/event-handlers/project-deleted.event-handler.ts index 1b8c730..73eeeb3 100644 --- a/libs/api/tracker/application/src/lib/event-handlers/project-deleted.event-handler.ts +++ b/libs/api/tracker/application/src/lib/event-handlers/project-deleted.event-handler.ts @@ -1,3 +1,4 @@ +import { GitIntegrationsRepository } from '../ports/git-integrations.repository'; import { EventsHandler, IEventHandler } from '@nestjs/cqrs'; import { ProjectDeletedEvent } from '@task-stream/api/tracker/domain'; 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, - * 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. */ @EventsHandler(ProjectDeletedEvent) @@ -18,11 +20,14 @@ export class ProjectDeletedEventHandler private readonly findProjects: FindProjectsRepository, private readonly upsertMaterializedProjectRepository: UpsertMaterializedProjectRepository, private readonly changeFeed: ChangeFeed, + private readonly gitIntegrations: GitIntegrationsRepository, ) {} async handle(event: ProjectDeletedEvent) { const project = await this.findProjects.findById(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). await this.changeFeed.publishProject( { diff --git a/libs/api/tracker/application/src/lib/notifications/notifications.event-handler.ts b/libs/api/tracker/application/src/lib/notifications/notifications.event-handler.ts index 838f162..79c1f31 100644 --- a/libs/api/tracker/application/src/lib/notifications/notifications.event-handler.ts +++ b/libs/api/tracker/application/src/lib/notifications/notifications.event-handler.ts @@ -10,10 +10,13 @@ import { mentionedUserIds, NotificationKind, ProjectMemberAddedEvent, + ProjectRole, StatusCategory, + SYSTEM_ACTOR_ID, TrackerEvent, withoutMentionMarkup, } from '@task-stream/api/tracker/domain'; +import { PROPOSAL_LABEL } from '../commands/submit-proposal.command-handler'; import { FindCommentsRepository } from '../ports/find-comments.repository'; import { FindIssuesRepository } from '../ports/find-issues.repository'; import { FindProjectsRepository } from '../ports/find-projects.repository'; @@ -117,6 +120,25 @@ export class NotificationsEventHandler implements IEventHandler { const memberIds = new Set(project.members.map((m) => m.user.id)); 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) { drafts.push({ userId: assigneeId, diff --git a/libs/api/tracker/application/src/lib/notifications/notifications.spec.ts b/libs/api/tracker/application/src/lib/notifications/notifications.spec.ts index 2f52108..4d54a62 100644 --- a/libs/api/tracker/application/src/lib/notifications/notifications.spec.ts +++ b/libs/api/tracker/application/src/lib/notifications/notifications.spec.ts @@ -1,6 +1,7 @@ import { CommentAddedEvent, IssueAssignedEvent, + IssueCreatedEvent, IssuePriority, IssueSprintChangedEvent, IssueStatusChangedEvent, @@ -100,7 +101,7 @@ function setup(mailEnabled = false) { name: 'Web', members: ['ana', 'bob', 'qa', 'pm'].map((id) => ({ user: { id, name: id }, - role: ProjectRole.MEMBER, + role: id === 'ana' ? ProjectRole.OWNER : ProjectRole.MEMBER, })), }), } 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 () => { const { handler, repo, mailed } = setup(true); repo.emailOn.add('bob'); diff --git a/libs/api/tracker/application/src/lib/ports/index.ts b/libs/api/tracker/application/src/lib/ports/index.ts index 0d5920d..46631ba 100644 --- a/libs/api/tracker/application/src/lib/ports/index.ts +++ b/libs/api/tracker/application/src/lib/ports/index.ts @@ -26,3 +26,4 @@ export * from './upsert-materialized-sprint.repository'; export * from './git-integrations.repository'; export * from './outgoing-webhooks.repository'; export * from './automations.repository'; +export * from './rate-limit.store'; diff --git a/libs/api/tracker/application/src/lib/ports/rate-limit.store.ts b/libs/api/tracker/application/src/lib/ports/rate-limit.store.ts new file mode 100644 index 0000000..8838bd3 --- /dev/null +++ b/libs/api/tracker/application/src/lib/ports/rate-limit.store.ts @@ -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; +} diff --git a/libs/api/tracker/domain/src/lib/read-models/notification.read-model.ts b/libs/api/tracker/domain/src/lib/read-models/notification.read-model.ts index 23638f8..e08a14e 100644 --- a/libs/api/tracker/domain/src/lib/read-models/notification.read-model.ts +++ b/libs/api/tracker/domain/src/lib/read-models/notification.read-model.ts @@ -14,6 +14,8 @@ export enum NotificationKind { MENTIONED = 'MENTIONED', /** Someone answered a comment of yours. */ REPLIED = 'REPLIED', + /** A bug report or an idea came in through a project's public form. */ + PROPOSED = 'PROPOSED', /** A ticket you follow changed. */ WATCHED = 'WATCHED', /** You were added to a project. */ diff --git a/libs/api/tracker/infrastructure/src/lib/redis/redis-rate-limit.store.ts b/libs/api/tracker/infrastructure/src/lib/redis/redis-rate-limit.store.ts new file mode 100644 index 0000000..ea403cf --- /dev/null +++ b/libs/api/tracker/infrastructure/src/lib/redis/redis-rate-limit.store.ts @@ -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 { + const name = `task-stream:rate:${key}`; + const count = await this.redis.incr(name); + if (count === 1) await this.redis.pexpire(name, windowMs); + return count; + } +} diff --git a/libs/api/tracker/infrastructure/src/lib/tracker-infrastructure.module.ts b/libs/api/tracker/infrastructure/src/lib/tracker-infrastructure.module.ts index e794cf2..d809c4d 100644 --- a/libs/api/tracker/infrastructure/src/lib/tracker-infrastructure.module.ts +++ b/libs/api/tracker/infrastructure/src/lib/tracker-infrastructure.module.ts @@ -32,6 +32,7 @@ import { UpsertMaterializedReleaseRepository, UpsertMaterializedTimelineRepository, UserDirectory, + RateLimitStore, } from '@task-stream/api/tracker/application'; import { IdentityUserDirectory } from './identity/identity-user-directory'; import { @@ -64,6 +65,7 @@ import { import { PostgresReleaseIssuesRepository } from './postgres/release-issues.repository'; import { PostgresTrackerStreamsRepository } from './postgres/tracker-streams.repository'; import { RedisChangeFeed } from './realtime/redis-change-feed'; +import { RedisRateLimitStore } from './redis/redis-rate-limit.store'; import { MongoNotificationsRepository } from './mongo/notifications.repository'; import { MongoSavedFiltersRepository } from './mongo/saved-filters.repository'; import { MongoGitIntegrationsRepository } from './mongo/git-integrations.repository'; @@ -144,6 +146,7 @@ const PORTS = [ { provide: NotificationMailer, useClass: SmtpNotificationMailer }, // Realtime → Redis pub/sub { provide: ChangeFeed, useClass: RedisChangeFeed }, + { provide: RateLimitStore, useClass: RedisRateLimitStore }, // Identity context (anti-corruption layer) { provide: UserDirectory, useClass: IdentityUserDirectory }, ]; diff --git a/libs/api/tracker/presenters/src/lib/http/dto/responses.dto.ts b/libs/api/tracker/presenters/src/lib/http/dto/responses.dto.ts index bb84c9a..13d8afd 100644 --- a/libs/api/tracker/presenters/src/lib/http/dto/responses.dto.ts +++ b/libs/api/tracker/presenters/src/lib/http/dto/responses.dto.ts @@ -443,6 +443,10 @@ export class IssueDto { }) blockedBy!: IssueParentDto[]; @ApiProperty({ description: 'How many want it done' }) votes!: number; + @ApiProperty({ + description: 'Whether I voted for it (false without a token)', + }) + voted!: boolean; @ApiProperty({ description: 'Place in the backlog and its board column (ascending, as text)', diff --git a/libs/api/tracker/presenters/src/lib/http/explore.controller.ts b/libs/api/tracker/presenters/src/lib/http/explore.controller.ts index 6841f7b..e5cca05 100644 --- a/libs/api/tracker/presenters/src/lib/http/explore.controller.ts +++ b/libs/api/tracker/presenters/src/lib/http/explore.controller.ts @@ -92,7 +92,7 @@ export class ExploreController { ): Promise { // Bots fill in every field: pretend it worked, and keep nothing. 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( project, body.kind, diff --git a/libs/api/tracker/presenters/src/lib/http/issues.controller.ts b/libs/api/tracker/presenters/src/lib/http/issues.controller.ts index c22116a..fd6418b 100644 --- a/libs/api/tracker/presenters/src/lib/http/issues.controller.ts +++ b/libs/api/tracker/presenters/src/lib/http/issues.controller.ts @@ -135,7 +135,10 @@ export class IssuesController { limit: query.limit, 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') @@ -173,7 +176,10 @@ export class IssuesController { limit: query.limit, 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') @@ -219,7 +225,7 @@ export class IssuesController { return Promise.all( epics.map(async (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, @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') @@ -392,6 +401,7 @@ export class IssuesController { viewerOf(me), await this.refs.projectId(viewerOf(me), project), ), + viewerOf(me), ); } diff --git a/libs/api/tracker/presenters/src/lib/http/projects.controller.ts b/libs/api/tracker/presenters/src/lib/http/projects.controller.ts index 6a5ce30..d98aa52 100644 --- a/libs/api/tracker/presenters/src/lib/http/projects.controller.ts +++ b/libs/api/tracker/presenters/src/lib/http/projects.controller.ts @@ -194,7 +194,7 @@ export class ProjectsController { return Promise.all( columns.map(async (column) => ({ ...column, - issues: await this.refs.issueDtos(column.issues), + issues: await this.refs.issueDtos(column.issues, viewerOf(me)), })), ); } diff --git a/libs/api/tracker/presenters/src/lib/http/proposal-limiter.ts b/libs/api/tracker/presenters/src/lib/http/proposal-limiter.ts index e03f4f6..300e16b 100644 --- a/libs/api/tracker/presenters/src/lib/http/proposal-limiter.ts +++ b/libs/api/tracker/presenters/src/lib/http/proposal-limiter.ts @@ -1,33 +1,24 @@ import { HttpException, HttpStatus, Injectable } from '@nestjs/common'; +import { RateLimitStore } from '@task-stream/api/tracker/application'; /** Proposals from one address within the window. */ const LIMIT = 5; const WINDOW_MS = 15 * 60_000; /** - * Keeps one address from flooding a project with proposals. In memory: - * enough for one instance, and an approximation with several (each counts - * its own), which is fine for what it guards against. + * Keeps one address from flooding a project with proposals. Counted in + * Redis, so the limit holds across every instance of the API. */ @Injectable() export class ProposalLimiter { - private readonly sent = new Map(); + constructor(private readonly store: RateLimitStore) {} - take(address: string): void { - const now = Date.now(); - const recent = (this.sent.get(address) ?? []).filter( - (at) => now - at < WINDOW_MS, - ); - if (recent.length >= LIMIT) + async take(address: string): Promise { + const count = await this.store.hit(`proposals:${address}`, WINDOW_MS); + if (count > LIMIT) throw new HttpException( 'Too many proposals from here; try again in a few minutes', 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); } } diff --git a/libs/api/tracker/presenters/src/lib/http/refs.ts b/libs/api/tracker/presenters/src/lib/http/refs.ts index 1ae2d6c..ab9182d 100644 --- a/libs/api/tracker/presenters/src/lib/http/refs.ts +++ b/libs/api/tracker/presenters/src/lib/http/refs.ts @@ -37,19 +37,27 @@ export class Refs { 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([ this.workflows.allowedTransitions(issue), this.blockers.blockedBy(issue), ]); // Who voted is only for telling someone whether they did. - const { voterIds: _voters, ...shown } = issue; - return { ...shown, allowedTransitions, blockedBy }; + const { voterIds, ...shown } = issue; + return { + ...shown, + allowedTransitions, + blockedBy, + voted: !!viewerId && voterIds.includes(viewerId), + }; } - issueDtos(issues: IssueReadModel[]) { - return Promise.all(issues.map((issue) => this.issueDto(issue))); + issueDtos(issues: IssueReadModel[], viewerId: string | null) { + return Promise.all(issues.map((issue) => this.issueDto(issue, viewerId))); } } diff --git a/libs/api/tracker/presenters/src/lib/http/releases.controller.ts b/libs/api/tracker/presenters/src/lib/http/releases.controller.ts index e59ff43..e27e428 100644 --- a/libs/api/tracker/presenters/src/lib/http/releases.controller.ts +++ b/libs/api/tracker/presenters/src/lib/http/releases.controller.ts @@ -177,7 +177,10 @@ export class ReleasesController { limit: page.limit, 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') diff --git a/libs/api/tracker/presenters/src/lib/http/sprints.controller.ts b/libs/api/tracker/presenters/src/lib/http/sprints.controller.ts index 4908329..3f7b6ca 100644 --- a/libs/api/tracker/presenters/src/lib/http/sprints.controller.ts +++ b/libs/api/tracker/presenters/src/lib/http/sprints.controller.ts @@ -223,7 +223,10 @@ export class SprintsController { limit: query.limit, 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') diff --git a/libs/web/identity/infrastructure/src/lib/graphql/generated.ts b/libs/web/identity/infrastructure/src/lib/graphql/generated.ts index 837b0dc..221741f 100644 --- a/libs/web/identity/infrastructure/src/lib/graphql/generated.ts +++ b/libs/web/identity/infrastructure/src/lib/graphql/generated.ts @@ -1,12 +1,43 @@ /** Internal type. DO NOT USE DIRECTLY. */ type Exact = { [K in keyof T]: T[K] }; /** Internal type. DO NOT USE DIRECTLY. */ -export type Incremental = T | { [P in keyof T]?: P extends ' $fragmentName' | '__typename' ? T[P] : never }; +export type Incremental = + | T + | { + [P in keyof T]?: P extends ' $fragmentName' | '__typename' ? T[P] : never; + }; 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 }; +}; -export type MeQuery = { me: { id: string, name: string, email: string, appRoles: Array } }; - - -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; \ No newline at end of file +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; diff --git a/libs/web/shared/infrastructure/src/index.ts b/libs/web/shared/infrastructure/src/index.ts index 5d05041..2d5d713 100644 --- a/libs/web/shared/infrastructure/src/index.ts +++ b/libs/web/shared/infrastructure/src/index.ts @@ -2,3 +2,4 @@ export * from './lib/api-config'; export * from './lib/access-token-source'; export * from './lib/graphql.provider'; export * from './lib/graphql-errors'; +export * from './lib/realtime-connection'; diff --git a/libs/web/shared/infrastructure/src/lib/graphql.provider.ts b/libs/web/shared/infrastructure/src/lib/graphql.provider.ts index d0e8af1..5a5430e 100644 --- a/libs/web/shared/infrastructure/src/lib/graphql.provider.ts +++ b/libs/web/shared/infrastructure/src/lib/graphql.provider.ts @@ -14,6 +14,7 @@ import { HttpLink } from 'apollo-angular/http'; import { createClient } from 'graphql-ws'; import { AccessTokenSource } from './access-token-source'; import { API_CONFIG } from './api-config'; +import { RealtimeConnection } from './realtime-connection'; /** * 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, * 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 { return makeEnvironmentProviders([ 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 | undefined; + let connectedBefore = false; const ws = new GraphQLWsLink( createClient({ url: config.graphqlWsUrl, lazy: true, retryAttempts: Infinity, shouldRetry: () => true, + keepAlive: PING_EVERY_MS, connectionParams: async () => { const token = await tokens.accessToken(); 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); + }, + }, }), ); diff --git a/libs/web/shared/infrastructure/src/lib/realtime-connection.ts b/libs/web/shared/infrastructure/src/lib/realtime-connection.ts new file mode 100644 index 0000000..070cea7 --- /dev/null +++ b/libs/web/shared/infrastructure/src/lib/realtime-connection.ts @@ -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(); + + /** Each time the socket reconnects (not the first time it connects). */ + readonly reconnected$: Observable = this.back.asObservable(); + + /** Called by the GraphQL client. */ + reconnected(): void { + this.back.next(); + } +} diff --git a/libs/web/tracker/application/src/lib/facades/facades.spec.ts b/libs/web/tracker/application/src/lib/facades/facades.spec.ts index d2840dc..1bc2b2a 100644 --- a/libs/web/tracker/application/src/lib/facades/facades.spec.ts +++ b/libs/web/tracker/application/src/lib/facades/facades.spec.ts @@ -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. */ function gateways() { + const reconnected = new Subject(); return { projects: { project: vi.fn(() => of(project)), @@ -92,7 +93,9 @@ function gateways() { releaseChanges: () => NEVER, sprintChanges: () => NEVER, projectChanges: vi.fn((): unknown => NEVER), + reconnects: () => reconnected, }, + reconnected, }; } @@ -161,6 +164,19 @@ describe('BoardFacade', () => { 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', () => { const { board, fakes } = setup(); const view = { @@ -480,7 +496,7 @@ describe('InboxFacade', () => { { provide: NotificationsGateway, useValue: gateway }, { provide: TrackerRealtimeGateway, - useValue: { notifications: () => arrivals }, + useValue: { notifications: () => arrivals, reconnects: () => NEVER }, }, { provide: Notifications, diff --git a/libs/web/tracker/application/src/lib/stores/backlog.store.ts b/libs/web/tracker/application/src/lib/stores/backlog.store.ts index e1ecc0c..0c45291 100644 --- a/libs/web/tracker/application/src/lib/stores/backlog.store.ts +++ b/libs/web/tracker/application/src/lib/stores/backlog.store.ts @@ -79,6 +79,7 @@ export const BacklogStore = signalStore( switchMap(({ payload }) => merge( realtime.issueChanges(payload.projectId).pipe(debounceTime(400)), + realtime.reconnects(), events.on(backlogApiEvents.moveFailed, backlogApiEvents.bulkDone), ).pipe( startWith(null), diff --git a/libs/web/tracker/application/src/lib/stores/board.store.ts b/libs/web/tracker/application/src/lib/stores/board.store.ts index 7bd6237..8ecc6e4 100644 --- a/libs/web/tracker/application/src/lib/stores/board.store.ts +++ b/libs/web/tracker/application/src/lib/stores/board.store.ts @@ -20,6 +20,7 @@ import { distinctUntilChanged, EMPTY, exhaustMap, + filter, map, merge, mergeMap, @@ -112,6 +113,12 @@ export const BoardStore = signalStore( load$: events .on(boardPageEvents.opened, boardPageEvents.filterChanged) .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( map(({ payload }) => payload.projectId), distinctUntilChanged(), diff --git a/libs/web/tracker/application/src/lib/stores/epics.store.ts b/libs/web/tracker/application/src/lib/stores/epics.store.ts index 50ceb69..a9fadbb 100644 --- a/libs/web/tracker/application/src/lib/stores/epics.store.ts +++ b/libs/web/tracker/application/src/lib/stores/epics.store.ts @@ -49,6 +49,7 @@ export const EpicsStore = signalStore( switchMap(({ payload }) => merge( realtime.issueChanges(payload.projectId), + realtime.reconnects(), // One's own vote, without waiting for the live update. events.on(epicsApiEvents.voted), ).pipe( diff --git a/libs/web/tracker/application/src/lib/stores/inbox.store.ts b/libs/web/tracker/application/src/lib/stores/inbox.store.ts index 1b9fd7d..d671551 100644 --- a/libs/web/tracker/application/src/lib/stores/inbox.store.ts +++ b/libs/web/tracker/application/src/lib/stores/inbox.store.ts @@ -85,7 +85,11 @@ export const InboxStore = signalStore( ) => { const closed$ = events.on(inboxPageEvents.closed); 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(() => gateway.mine(false, LIMIT).pipe( mapResponse({ diff --git a/libs/web/tracker/application/src/lib/stores/issue.store.ts b/libs/web/tracker/application/src/lib/stores/issue.store.ts index 72b13ad..59c7699 100644 --- a/libs/web/tracker/application/src/lib/stores/issue.store.ts +++ b/libs/web/tracker/application/src/lib/stores/issue.store.ts @@ -243,39 +243,41 @@ export const IssueStore = signalStore( * API projects its read model a moment after answering, so the * ticket, its comments and its subtasks are asked for twice. */ - catchUp$: events - .on( + catchUp$: merge( + events.on( issueApiEvents.commandSucceeded, issueApiEvents.checklistSaved, issuePageEvents.imagePasted, - ) - .pipe( - map(() => store.issue()), - filter((issue): issue is Issue => !!issue), - switchMap((issue) => - merge( - ...[300, 1500].map((ms) => - timer(ms).pipe( - switchMap(() => - merge( - fetchIssue(issue.id), - comments - .list(issue.id) - .pipe( - map((list) => issueApiEvents.commentsLoaded(list)), - ), - [ - issueRealtimeEvents.childrenTouched({ - issueId: issue.id, - }), - ], - ), + ), + // Back after a cut: comments or changes may have been missed. + realtime.reconnects(), + ).pipe( + map(() => store.issue()), + filter((issue): issue is Issue => !!issue), + switchMap((issue) => + merge( + ...[300, 1500].map((ms) => + timer(ms).pipe( + switchMap(() => + merge( + fetchIssue(issue.id), + comments + .list(issue.id) + .pipe( + map((list) => issueApiEvents.commentsLoaded(list)), + ), + [ + issueRealtimeEvents.childrenTouched({ + issueId: issue.id, + }), + ], ), ), ), ), ), ), + ), liveComments$: events.on(issuePageEvents.opened).pipe( switchMap(() => loaded$.pipe(map(({ payload }) => payload.id))), filter((id, index) => index === 0 || id !== store.issue()?.id), diff --git a/libs/web/tracker/application/src/lib/stores/list.store.ts b/libs/web/tracker/application/src/lib/stores/list.store.ts index 2bc0136..ea82b99 100644 --- a/libs/web/tracker/application/src/lib/stores/list.store.ts +++ b/libs/web/tracker/application/src/lib/stores/list.store.ts @@ -100,6 +100,7 @@ export const ListStore = signalStore( ), merge( realtime.issueChanges(payload.projectId), + realtime.reconnects(), events.on( listApiEvents.changed, listApiEvents.changeFailed, diff --git a/libs/web/tracker/application/src/lib/stores/project.store.ts b/libs/web/tracker/application/src/lib/stores/project.store.ts index 1eb6a45..763a4a7 100644 --- a/libs/web/tracker/application/src/lib/stores/project.store.ts +++ b/libs/web/tracker/application/src/lib/stores/project.store.ts @@ -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( switchMap(({ payload }) => merge( diff --git a/libs/web/tracker/application/src/lib/stores/projects.store.ts b/libs/web/tracker/application/src/lib/stores/projects.store.ts index 06b7afc..83e08a5 100644 --- a/libs/web/tracker/application/src/lib/stores/projects.store.ts +++ b/libs/web/tracker/application/src/lib/stores/projects.store.ts @@ -14,7 +14,7 @@ import { ProjectsGateway, TrackerRealtimeGateway, } from '@task-stream/web/tracker/domain'; -import { exhaustMap, map, switchMap, tap } from 'rxjs'; +import { exhaustMap, map, merge, switchMap, tap } from 'rxjs'; import { projectsApiEvents, projectsPageEvents, @@ -74,7 +74,11 @@ export const ProjectsStore = signalStore( router = inject(Router), 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(() => projects.myProjects().pipe( mapResponse({ diff --git a/libs/web/tracker/domain/src/lib/models/enums.ts b/libs/web/tracker/domain/src/lib/models/enums.ts index a548ec8..652edb4 100644 --- a/libs/web/tracker/domain/src/lib/models/enums.ts +++ b/libs/web/tracker/domain/src/lib/models/enums.ts @@ -54,6 +54,7 @@ export enum NotificationKind { COMMENTED = 'COMMENTED', MENTIONED = 'MENTIONED', REPLIED = 'REPLIED', + PROPOSED = 'PROPOSED', WATCHED = 'WATCHED', ADDED_TO_PROJECT = 'ADDED_TO_PROJECT', REMINDER = 'REMINDER', diff --git a/libs/web/tracker/domain/src/lib/ports/gateways.ts b/libs/web/tracker/domain/src/lib/ports/gateways.ts index 2792086..c5b19d3 100644 --- a/libs/web/tracker/domain/src/lib/ports/gateways.ts +++ b/libs/web/tracker/domain/src/lib/ports/gateways.ts @@ -370,4 +370,9 @@ export abstract class TrackerRealtimeGateway { abstract projectChanges(projectId: string): Observable; abstract myProjectsChanges(): Observable; abstract notifications(): Observable; + /** + * Each time the live connection comes back after a cut: what happened + * meanwhile was not received, so views reload. + */ + abstract reconnects(): Observable; } diff --git a/libs/web/tracker/infrastructure/src/lib/adapters/graphql-realtime.gateway.ts b/libs/web/tracker/infrastructure/src/lib/adapters/graphql-realtime.gateway.ts index 5cc9f18..1ae3f69 100644 --- a/libs/web/tracker/infrastructure/src/lib/adapters/graphql-realtime.gateway.ts +++ b/libs/web/tracker/infrastructure/src/lib/adapters/graphql-realtime.gateway.ts @@ -1,5 +1,8 @@ import { inject, Injectable } from '@angular/core'; -import { AccessTokenSource } from '@task-stream/web/shared/infrastructure'; +import { + AccessTokenSource, + RealtimeConnection, +} from '@task-stream/web/shared/infrastructure'; import { Activity, CommentChanged, @@ -38,6 +41,11 @@ import { export class GraphqlRealtimeGateway extends TrackerRealtimeGateway { private readonly gql = inject(GraphqlOperations); private readonly tokens = inject(AccessTokenSource); + private readonly connection = inject(RealtimeConnection); + + reconnects(): Observable { + return this.connection.reconnected$; + } private signedIn(stream: () => Observable): Observable { return defer(() => this.tokens.accessToken()).pipe( diff --git a/libs/web/tracker/infrastructure/src/lib/graphql/generated.ts b/libs/web/tracker/infrastructure/src/lib/graphql/generated.ts index b0b5349..0d4280f 100644 --- a/libs/web/tracker/infrastructure/src/lib/graphql/generated.ts +++ b/libs/web/tracker/infrastructure/src/lib/graphql/generated.ts @@ -212,6 +212,7 @@ export type NotificationKind = | 'ASSIGNED' | 'COMMENTED' | 'MENTIONED' + | 'PROPOSED' | 'QA_FAILED' | 'REMINDER' | 'REPLIED' diff --git a/libs/web/tracker/presenters/src/lib/pages/board.page.ts b/libs/web/tracker/presenters/src/lib/pages/board.page.ts index 405bd77..04d3bcd 100644 --- a/libs/web/tracker/presenters/src/lib/pages/board.page.ts +++ b/libs/web/tracker/presenters/src/lib/pages/board.page.ts @@ -772,6 +772,9 @@ export class BoardPage { event: CdkDragDrop, ): void { 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 issueId = event.item.data.id; const target = this.board.columns().find((c) => c.status === to); diff --git a/libs/web/tracker/presenters/src/lib/pages/issue.page.scss b/libs/web/tracker/presenters/src/lib/pages/issue.page.scss index 13e8a41..9972661 100644 --- a/libs/web/tracker/presenters/src/lib/pages/issue.page.scss +++ b/libs/web/tracker/presenters/src/lib/pages/issue.page.scss @@ -562,102 +562,6 @@ a.ts-key { 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 - } - @if (project.myRole()) { -
- - - -
- @for (emoji of reactions; track emoji) { - - } -
-
- } - + } @if (!comment.deleted && filesOf(comment).length) {
@@ -1194,30 +1149,6 @@ export class IssuePage { () => this.facade.comments().filter((c) => !c.deleted).length, ); protected readonly commentFiles = signal([]); - 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 { this.facade.duplicate().subscribe({ next: (key) => { @@ -1285,8 +1216,24 @@ export class IssuePage { protected readonly maxFileBytes = MAX_FILE_BYTES; /** 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( - () => 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[] => this.facade diff --git a/libs/web/tracker/presenters/src/lib/pages/list.page.ts b/libs/web/tracker/presenters/src/lib/pages/list.page.ts index f7b36d9..e63ab99 100644 --- a/libs/web/tracker/presenters/src/lib/pages/list.page.ts +++ b/libs/web/tracker/presenters/src/lib/pages/list.page.ts @@ -29,6 +29,7 @@ import { IssueSummary, StatusCategory, } from '@task-stream/web/tracker/domain'; +import { BlockedComponent } from '../ui/blocked'; import { BulkBarComponent } from '../ui/bulk-bar'; import { VoteComponent } from '../ui/vote'; import { @@ -77,6 +78,7 @@ const numberOf = (key: string) => Number(key.split('-').pop()); selector: 'ts-list-page', changeDetection: ChangeDetectionStrategy.OnPush, imports: [ + BlockedComponent, BulkBarComponent, VoteComponent, RouterLink, @@ -183,12 +185,11 @@ const numberOf = (key: string) => Number(key.split('-').pop()); issue.title }} @if (issue.blockedBy.length) { - - Bloqueado - + [blockers]="issue.blockedBy" + [compact]="true" + /> } @if (issue.parent; as parent) { @@ -388,16 +389,7 @@ const numberOf = (key: string) => Number(key.split('-').pop()); } } .ts-list__blocked { - display: inline-flex; - gap: 0.25rem; - align-items: center; 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 { display: inline-flex; @@ -644,10 +636,6 @@ export class ListPage { this.facade.assign(issue.id, assigneeId); } - protected blockersOf(issue: IssueSummary): string { - return issue.blockedBy.map((b) => b.key).join(', '); - } - protected isLate(issue: IssueSummary): boolean { return ( !!issue.dueDate && diff --git a/libs/web/tracker/presenters/src/lib/ui/blocked.ts b/libs/web/tracker/presenters/src/lib/ui/blocked.ts new file mode 100644 index 0000000..7eb6480 --- /dev/null +++ b/libs/web/tracker/presenters/src/lib/ui/blocked.ts @@ -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: ` +