diff --git a/apps/api/e2e/tracker.e2e-spec.ts b/apps/api/e2e/tracker.e2e-spec.ts index 289bf06..056c1fd 100644 --- a/apps/api/e2e/tracker.e2e-spec.ts +++ b/apps/api/e2e/tracker.e2e-spec.ts @@ -1700,4 +1700,59 @@ describe('task-stream (e2e)', () => { .summary, ).toMatch(/tu equipo Frontend/); }); + + it('tells who else is on a ticket, and who is editing it', async () => { + const key = 'P' + Date.now().toString(36).slice(-5).toUpperCase(); + await rest('token-ana', 'POST', '/projects', { key, name: key }); + await eventually( + () => rest('token-ana', 'GET', `/projects/${key}`), + (r) => r.status === 200, + ); + await rest('token-ana', 'POST', `/projects/${key}/members`, { + userId: '102', + }); + const created = await rest('token-ana', 'POST', `/projects/${key}/issues`, { + type: 'TASK', + title: 'Aquí estamos', + }); + const issue = await eventually( + () => rest('token-bob', 'GET', `/issues/${created.body['key']}`), + (r) => r.status === 200, + ); + const id = issue.body['id']; + + expect( + ( + await rest('token-ana', 'PUT', `/issues/${id}/presence`, { + editing: false, + }) + ).body, + ).toEqual([]); + const { touchPresence } = await gql( + 'token-bob', + `mutation($i: ID!) { touchPresence(issueId: $i, editing: true) { user { id name } editing } }`, + { i: id }, + ); + expect(touchPresence).toEqual([ + { user: { id: '101', name: 'Ana (lead)' }, editing: false }, + ]); + const seen = await rest('token-ana', 'PUT', `/issues/${id}/presence`, {}); + expect(seen.body).toEqual([ + expect.objectContaining({ + user: { id: '102', name: 'Bob (frontend)' }, + editing: true, + }), + ]); + // Outsiders cannot even ask (a private ticket does not exist for them). + expect( + (await rest('token-eve', 'PUT', `/issues/${id}/presence`, {})).status, + ).toBe(404); + + expect( + (await rest('token-bob', 'DELETE', `/issues/${id}/presence`)).status, + ).toBe(204); + expect( + (await rest('token-ana', 'PUT', `/issues/${id}/presence`, {})).body, + ).toEqual([]); + }); }); diff --git a/apps/api/openapi.json b/apps/api/openapi.json index 015eac4..a0e7656 100644 --- a/apps/api/openapi.json +++ b/apps/api/openapi.json @@ -2185,6 +2185,173 @@ ] } }, + "/api/issues/{issue}/presence": { + "put": { + "description": "Send it every ~10 s while the page is open; entries fade after 30 s without one. Answers who else is there, the ones editing first.", + "operationId": "IssuesController_touchPresence", + "parameters": [ + { + "name": "issue", + "required": true, + "in": "path", + "description": "Ticket id or key", + "schema": { + "example": "WEB-12", + "type": "string" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PresenceBodyDto" + } + } + } + }, + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/PresenceDto" + } + } + } + } + }, + "400": { + "description": "Invalid input", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorDto" + } + } + } + }, + "401": { + "description": "Missing or rejected Pireon token", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorDto" + } + } + } + }, + "403": { + "description": "Not a member of the project (or not allowed)", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorDto" + } + } + } + }, + "404": { + "description": "Not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorDto" + } + } + } + } + }, + "security": [ + { + "pireon-oauth": [] + }, + { + "pireon": [] + } + ], + "summary": "I am on this ticket (heartbeat)", + "tags": [ + "Issues" + ] + }, + "delete": { + "operationId": "IssuesController_leavePresence", + "parameters": [ + { + "name": "issue", + "required": true, + "in": "path", + "description": "Ticket id or key", + "schema": { + "example": "WEB-12", + "type": "string" + } + } + ], + "responses": { + "204": { + "description": "" + }, + "400": { + "description": "Invalid input", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorDto" + } + } + } + }, + "401": { + "description": "Missing or rejected Pireon token", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorDto" + } + } + } + }, + "403": { + "description": "Not a member of the project (or not allowed)", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorDto" + } + } + } + }, + "404": { + "description": "Not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorDto" + } + } + } + } + }, + "security": [ + { + "pireon-oauth": [] + }, + { + "pireon": [] + } + ], + "summary": "I left this ticket's page", + "tags": [ + "Issues" + ] + } + }, "/api/issues/{issue}/watch": { "put": { "description": "Followers are told when it moves, is reassigned or gets a comment. Commenting follows it too.", @@ -13248,6 +13415,38 @@ "role" ] }, + "PresenceBodyDto": { + "type": "object", + "properties": { + "editing": { + "type": "object", + "default": false, + "description": "Editing the ticket or writing a comment" + } + } + }, + "PresenceDto": { + "type": "object", + "properties": { + "user": { + "$ref": "#/components/schemas/UserRefDto" + }, + "editing": { + "type": "boolean", + "description": "Editing the ticket or writing a comment" + }, + "at": { + "type": "string", + "format": "date-time", + "description": "Last heartbeat" + } + }, + "required": [ + "user", + "editing", + "at" + ] + }, "IssuePageDto": { "type": "object", "properties": { diff --git a/apps/api/schema.gql b/apps/api/schema.gql index 4ed2218..ccc372e 100644 --- a/apps/api/schema.gql +++ b/apps/api/schema.gql @@ -871,6 +871,9 @@ type Mutation { duplicateIssue(issueId: ID!): CommandResult! editComment(body: String!, commentId: ID!): CommandResult! + """I left this ticket's page""" + leavePresence(issueId: ID!): Boolean! + """Relate tickets, e.g. FE DEPENDS_ON BE, QA TESTS FE""" linkIssues(input: LinkIssuesInput!): CommandResult! @@ -933,6 +936,11 @@ type Mutation { """Send a ping now and tell how it went""" testWebhook(webhookId: ID!): WebhookDelivery! + """ + I am on this ticket (send every ~10 s while the page is open): answers who else is, the ones editing first + """ + touchPresence(editing: Boolean! = false, issueId: ID!): [Presence!]! + """ To the trash (whoever opened it, or an owner/admin): hidden everywhere, deleted for good after 30 days """ @@ -1074,6 +1082,16 @@ type OptionCount { value: String! } +"""Someone on a ticket right now (heartbeat in the last 30 s)""" +type Presence { + """Last heartbeat""" + at: String! + + """Editing the ticket or writing a comment""" + editing: Boolean! + user: UserRef! +} + type PriorityCount { count: Int! priority: IssuePriority! diff --git a/apps/mcp/src/app/api/taskstream.schema.ts b/apps/mcp/src/app/api/taskstream.schema.ts index ca3a735..66bfed5 100644 --- a/apps/mcp/src/app/api/taskstream.schema.ts +++ b/apps/mcp/src/app/api/taskstream.schema.ts @@ -397,6 +397,27 @@ export interface paths { patch: operations["ProjectsController_changeMemberRole"]; trace?: never; }; + "/api/issues/{issue}/presence": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + /** + * I am on this ticket (heartbeat) + * @description Send it every ~10 s while the page is open; entries fade after 30 s without one. Answers who else is there, the ones editing first. + */ + put: operations["IssuesController_touchPresence"]; + post?: never; + /** I left this ticket's page */ + delete: operations["IssuesController_leavePresence"]; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/issues/{issue}/watch": { parameters: { query?: never; @@ -2419,6 +2440,23 @@ export interface components { ChangeMemberRoleDto: { role: components["schemas"]["ProjectRole"]; }; + PresenceBodyDto: { + /** + * @description Editing the ticket or writing a comment + * @default false + */ + editing: Record; + }; + PresenceDto: { + user: components["schemas"]["UserRefDto"]; + /** @description Editing the ticket or writing a comment */ + editing: boolean; + /** + * Format: date-time + * @description Last heartbeat + */ + at: string; + }; IssuePageDto: { items: components["schemas"]["IssueDto"][]; /** @description Total matches (for pagination) */ @@ -4740,6 +4778,124 @@ export interface operations { }; }; }; + IssuesController_touchPresence: { + parameters: { + query?: never; + header?: never; + path: { + /** @description Ticket id or key */ + issue: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["PresenceBodyDto"]; + }; + }; + responses: { + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["PresenceDto"][]; + }; + }; + /** @description Invalid input */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiErrorDto"]; + }; + }; + /** @description Missing or rejected Pireon token */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiErrorDto"]; + }; + }; + /** @description Not a member of the project (or not allowed) */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiErrorDto"]; + }; + }; + /** @description Not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiErrorDto"]; + }; + }; + }; + }; + IssuesController_leavePresence: { + parameters: { + query?: never; + header?: never; + path: { + /** @description Ticket id or key */ + issue: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + 204: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Invalid input */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiErrorDto"]; + }; + }; + /** @description Missing or rejected Pireon token */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiErrorDto"]; + }; + }; + /** @description Not a member of the project (or not allowed) */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiErrorDto"]; + }; + }; + /** @description Not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiErrorDto"]; + }; + }; + }; + }; IssuesController_watch: { parameters: { query?: never; diff --git a/docs/03-api.md b/docs/03-api.md index 52b3ce6..c53240b 100644 --- a/docs/03-api.md +++ b/docs/03-api.md @@ -135,6 +135,14 @@ estaban. Un ticket va a un equipo con `team` (en `createIssue` / `updateIssue` y `teams` (`?team=FRONT`). En un comentario, `@[Frontend](team-FRONT)` avisa a cada miembro del equipo («mencionó a tu equipo…»). +### Presencia + +Quién más está en un ticket ahora. La página de un ticket late cada 10 s con `touchPresence(issueId, editing)` (o +`PUT /api/issues/{issue}/presence` con `{ editing }`), y la respuesta es quién más está, primero los que editan. Se +guarda en Redis (un hash por ticket, `task-stream:presence:`) y una entrada sin latido en 30 s desaparece sola; +`leavePresence` (`DELETE …/presence`) la quita al salir. No es historia: no pasa por el event store. Solo para quien +puede leer el ticket. + ### Definición de «Hecho» `settings.checklistBeforeDone` (desactivada por defecto): con ella, un ticket no pasa a un estado de categoría `DONE` @@ -657,23 +665,23 @@ Lo que cada usuario puede ver o cambiar depende de su rol en cada proyecto, como Proyectos y tickets se pueden indicar por **id o por clave** (`WEB`, `WEB-12`). -| Recurso | Rutas | -| ----------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| Usuarios | `GET /api/users/me`, `GET /api/users?search=`, `GET /api/users/{id}` | -| Proyectos | `GET/POST /api/projects`, `GET/PATCH/DELETE /api/projects/{project}` (`DELETE …?confirm=CLAVE`), `POST …/archive`, `POST …/unarchive`, `PUT …/settings`, `…/board`, `…/epics`, `…/stats`, `…/analytics?days=`, `…/insights`, `…/activity`, `…/members` (POST, PATCH, DELETE) | -| Tickets | `GET /api/me/issues` (mi trabajo; filtros `project`, `priority`, `bugsOnly`, `overdue`), `GET/POST /api/projects/{project}/issues`, `GET/PATCH /api/issues/{issue}`, `PUT …/status`, `PUT …/assignee`, `PUT …/release`, `PUT/DELETE …/watch`, `PUT …/parent`, `PUT …/rank`, `POST …/checklist`, `PATCH/DELETE …/checklist/{item}`, `POST/DELETE …/links`, `GET/POST …/comments`, `GET …/history`, `POST /api/issues/{issue}/duplicate`, `PUT/DELETE …/vote` | -| Comentarios | `PATCH/DELETE /api/comments/{commentId}`, `POST …/reactions` | -| Sprints | `GET/POST /api/projects/{project}/sprints`, `GET/PATCH/DELETE /api/sprints/{sprint}`, `POST …/start`, `POST …/close`, `GET …/progress`, `…/burndown`, `…/issues`, `PUT /api/issues/{issue}/sprint` | -| Vistas | `GET/POST /api/projects/{project}/filters`, `DELETE /api/filters/{id}` | -| Avisos | `GET /api/me/notifications`, `POST /api/me/notifications/read`, `GET/PUT /api/me/notification-settings` | -| Adjuntos | `POST /api/issues/{issue}/attachments` (multipart: `file` y opcional `commentId`), `GET/DELETE /api/issues/{issue}/attachments/{id}` | -| Releases | `GET /api/me/releases` (próximas), `GET/POST /api/projects/{project}/releases`, `GET/PATCH /api/releases/{id}`, `PUT …/notes`, `GET …/notes/draft`, `…/progress`, `…/burnup`, `…/issues`, `POST …/publish`, `POST …/archive` | -| Explorar | `GET /api/explore/projects` (proyectos públicos, sin token) | -| Importar | `GET /api/projects/{project}/export.csv`, `POST /api/projects/{project}/import?dryRun=` (multipart `file`) | -| Webhooks | `GET /api/webhook-events`, `GET/POST /api/projects/{project}/webhooks`, `PATCH/DELETE /api/outgoing-webhooks/{id}`, `POST …/test` | -| Git | `GET /api/projects/{project}/git-integration`, `POST …/git-integration/repositories`, `POST …/repositories/{id}/secret`, `DELETE …/repositories/{id}`, `POST /api/webhooks/git/{project}` (lo llama el host de Git) | -| Explorar | `GET /api/explore/projects/{project}/showcase` (sin token, proyectos públicos): hoja de ruta con votos, versiones, sprint en curso, tickets `good-first-issue` y propuestas abiertas; `POST …/proposals` (sin token) | -| Sistema | `GET /api/health`, `GET /api/auth/config`, `POST /api/auth/token`, `POST /api/webhooks/pireon` | +| Recurso | Rutas | +| ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| Usuarios | `GET /api/users/me`, `GET /api/users?search=`, `GET /api/users/{id}` | +| Proyectos | `GET/POST /api/projects`, `GET/PATCH/DELETE /api/projects/{project}` (`DELETE …?confirm=CLAVE`), `POST …/archive`, `POST …/unarchive`, `PUT …/settings`, `…/board`, `…/epics`, `…/stats`, `…/analytics?days=`, `…/insights`, `…/activity`, `…/members` (POST, PATCH, DELETE) | +| Tickets | `GET /api/me/issues` (mi trabajo; filtros `project`, `priority`, `bugsOnly`, `overdue`), `GET/POST /api/projects/{project}/issues`, `GET/PATCH /api/issues/{issue}`, `PUT …/status`, `PUT …/assignee`, `PUT …/release`, `PUT/DELETE …/watch`, `PUT/DELETE …/presence`, `PUT …/parent`, `PUT …/rank`, `POST …/checklist`, `PATCH/DELETE …/checklist/{item}`, `POST/DELETE …/links`, `GET/POST …/comments`, `GET …/history`, `POST /api/issues/{issue}/duplicate`, `PUT/DELETE …/vote` | +| Comentarios | `PATCH/DELETE /api/comments/{commentId}`, `POST …/reactions` | +| Sprints | `GET/POST /api/projects/{project}/sprints`, `GET/PATCH/DELETE /api/sprints/{sprint}`, `POST …/start`, `POST …/close`, `GET …/progress`, `…/burndown`, `…/issues`, `PUT /api/issues/{issue}/sprint` | +| Vistas | `GET/POST /api/projects/{project}/filters`, `DELETE /api/filters/{id}` | +| Avisos | `GET /api/me/notifications`, `POST /api/me/notifications/read`, `GET/PUT /api/me/notification-settings` | +| Adjuntos | `POST /api/issues/{issue}/attachments` (multipart: `file` y opcional `commentId`), `GET/DELETE /api/issues/{issue}/attachments/{id}` | +| Releases | `GET /api/me/releases` (próximas), `GET/POST /api/projects/{project}/releases`, `GET/PATCH /api/releases/{id}`, `PUT …/notes`, `GET …/notes/draft`, `…/progress`, `…/burnup`, `…/issues`, `POST …/publish`, `POST …/archive` | +| Explorar | `GET /api/explore/projects` (proyectos públicos, sin token) | +| Importar | `GET /api/projects/{project}/export.csv`, `POST /api/projects/{project}/import?dryRun=` (multipart `file`) | +| Webhooks | `GET /api/webhook-events`, `GET/POST /api/projects/{project}/webhooks`, `PATCH/DELETE /api/outgoing-webhooks/{id}`, `POST …/test` | +| Git | `GET /api/projects/{project}/git-integration`, `POST …/git-integration/repositories`, `POST …/repositories/{id}/secret`, `DELETE …/repositories/{id}`, `POST /api/webhooks/git/{project}` (lo llama el host de Git) | +| Explorar | `GET /api/explore/projects/{project}/showcase` (sin token, proyectos públicos): hoja de ruta con votos, versiones, sprint en curso, tickets `good-first-issue` y propuestas abiertas; `POST …/proposals` (sin token) | +| Sistema | `GET /api/health`, `GET /api/auth/config`, `POST /api/auth/token`, `POST /api/webhooks/pireon` | Los archivos van por REST porque GraphQL no está hecho para bytes; la lista de adjuntos de un ticket llega con el ticket (`attachments`, en GraphQL y en REST). La descarga sirve en línea solo imágenes, vídeo, audio, PDF y texto diff --git a/docs/04-cliente-web.md b/docs/04-cliente-web.md index dd857c0..b9a60fa 100644 --- a/docs/04-cliente-web.md +++ b/docs/04-cliente-web.md @@ -288,7 +288,7 @@ visualización de la plataforma: | `/` | Inicio: «Ir a…» (`WEB-12`), mis vistas guardadas (`ts-my-views`: una barra para elegir, sus tickets y «+ Nueva vista»), mi trabajo (`ts-my-work`) y mis proyectos | | `/explorar` | Proyectos públicos (sin sesión) | | `/p/:key/board` | Tablero kanban (`?release=` y `?assignee=` preseleccionan los filtros) | -| `/p/:key/issues/:issueKey` | Ticket | +| `/p/:key/issues/:issueKey` | Ticket. Encima del título, quién más lo está viendo o editando ahora (`PresenceFacade`: late cada 10 s; editar es tener abierto el formulario del ticket o de un comentario, o estar escribiendo uno) | | `/p/:key/releases` | Releases | | `/p/:key/report/:kind/:id` | Informe para imprimir de un sprint (`sprint`) o una release (`release`): datos, totales, notas y tickets por etapa. «Imprimir o guardar en PDF» abre el diálogo del navegador; al imprimir se quitan la cabecera, las pestañas y los fondos (`styles.scss`) | | `/p/:key/roadmap` | Hoja de ruta: épicas en el tiempo, con sprints y releases; flechas «bloquea» / «depende de» entre épicas y aviso si una empieza antes de que acabe la que la bloquea | diff --git a/docs/10-futuro.md b/docs/10-futuro.md index acf7381..9f35165 100644 --- a/docs/10-futuro.md +++ b/docs/10-futuro.md @@ -39,7 +39,6 @@ Nada: la tanda está hecha (ver 10.5). vista. - **Entornos de despliegue**: en qué entorno está cada ticket (pruebas, producción), desde los avisos de la integración continua. -- **Presencia**: ver quién está mirando o editando el mismo ticket. - **Rol «invitado»**: solo ve los tickets en los que participa (clientes, externos). - **Página pública de novedades**: las notas de versión publicadas en una página por proyecto, con RSS. - **Vista de cartera**: progreso de épicas y releases de todos los proyectos en una página. @@ -121,3 +120,4 @@ Mucho trabajo para lo que aportan hoy; se pueden recuperar si alguien los necesi - Archivar un proyecto: queda en solo lectura (se ve todo, no cambia nada) hasta que su propietario lo desarchiva. - Equipos dentro del proyecto: grupos de miembros en Configuración; los tickets se asignan a uno, la lista filtra por equipo y «@equipo» avisa a todos sus miembros. +- Presencia: en cada ticket se ve quién más lo está viendo o editando en ese momento. diff --git a/docs/TaskStream-Documentacion.docx b/docs/TaskStream-Documentacion.docx index 7e0ea8c..cecba35 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 0ee6137..507ae63 100644 Binary files a/docs/TaskStream-Documentacion.pdf and b/docs/TaskStream-Documentacion.pdf differ diff --git a/libs/api/tracker/application/src/index.ts b/libs/api/tracker/application/src/index.ts index 50c04c4..9105819 100644 --- a/libs/api/tracker/application/src/index.ts +++ b/libs/api/tracker/application/src/index.ts @@ -32,3 +32,5 @@ export * from './lib/saved-filters/saved-filters'; export * from './lib/saved-filters/my-views'; export * from './lib/ports/my-views.repository'; export * from './lib/tracker-application.module'; +export * from './lib/ports/presence.store'; +export * from './lib/presence.service'; diff --git a/libs/api/tracker/application/src/lib/ports/presence.store.ts b/libs/api/tracker/application/src/lib/ports/presence.store.ts new file mode 100644 index 0000000..356fe09 --- /dev/null +++ b/libs/api/tracker/application/src/lib/ports/presence.store.ts @@ -0,0 +1,24 @@ +/** Someone on a ticket's page just now. */ +export interface PresenceEntry { + user: { id: string; name: string }; + /** Editing the ticket or writing a comment, not only looking. */ + editing: boolean; + /** Last heartbeat (ISO). */ + at: string; +} + +/** + * Who is on each ticket's page, shared by every instance of the API. + * Entries fade on their own: a page that stops beating is gone. + */ +export abstract class PresenceStore { + /** Records (or refreshes) one person on a ticket; lasts `ttlMs`. */ + abstract touch( + issueId: string, + entry: PresenceEntry, + ttlMs: number, + ): Promise; + /** Everyone whose heartbeat is newer than `since` (ms epoch). */ + abstract list(issueId: string, since: number): Promise; + abstract leave(issueId: string, userId: string): Promise; +} diff --git a/libs/api/tracker/application/src/lib/presence.service.spec.ts b/libs/api/tracker/application/src/lib/presence.service.spec.ts new file mode 100644 index 0000000..fa701a9 --- /dev/null +++ b/libs/api/tracker/application/src/lib/presence.service.spec.ts @@ -0,0 +1,69 @@ +import { ForbiddenError, NotFoundError } from '@task-stream/api/shared/domain'; +import { IssueReadModel } from '@task-stream/api/tracker/domain'; +import { FindIssuesRepository } from './ports/find-issues.repository'; +import { PresenceEntry, PresenceStore } from './ports/presence.store'; +import { PRESENCE_TTL_MS, PresenceService } from './presence.service'; +import { ReadAccess } from './queries/read-access'; + +class MemoryPresence extends PresenceStore { + readonly entries = new Map>(); + async touch(issueId: string, entry: PresenceEntry) { + const here = this.entries.get(issueId) ?? new Map(); + here.set(entry.user.id, entry); + this.entries.set(issueId, here); + } + async list(issueId: string, since: number) { + return [...(this.entries.get(issueId)?.values() ?? [])].filter( + (e) => Date.parse(e.at) >= since, + ); + } + async leave(issueId: string, userId: string) { + this.entries.get(issueId)?.delete(userId); + } +} + +const issues = { + findById: async (id: string) => + id === 'i1' + ? ({ id: 'i1', projectId: 'p1', trashedAt: null } as IssueReadModel) + : null, +} as unknown as FindIssuesRepository; +const access = { + assertCanView: async (_projectId: string, viewerId: string) => { + if (viewerId === 'eve') throw new ForbiddenError('No'); + return 'MEMBER'; + }, +} as unknown as ReadAccess; + +describe('PresenceService', () => { + it('tells who else is on a ticket, the ones editing first', async () => { + const store = new MemoryPresence(); + const presence = new PresenceService(store, issues, access); + await presence.touch({ id: 'ana', name: 'Ana' }, 'i1', false); + await presence.touch({ id: 'cris', name: 'Cris' }, 'i1', true); + const seen = await presence.touch({ id: 'bob', name: 'Bob' }, 'i1', false); + expect(seen.map((p) => [p.user.name, p.editing])).toEqual([ + ['Cris', true], + ['Ana', false], + ]); + + // A page that stops beating fades out; leaving takes one out at once. + store.entries.get('i1')!.get('ana')!.at = new Date( + Date.now() - PRESENCE_TTL_MS - 1000, + ).toISOString(); + await presence.leave('cris', 'i1'); + expect( + await presence.touch({ id: 'bob', name: 'Bob' }, 'i1', false), + ).toEqual([]); + }); + + it('only for whoever can read the ticket', async () => { + const presence = new PresenceService(new MemoryPresence(), issues, access); + await expect( + presence.touch({ id: 'eve', name: 'Eve' }, 'i1', false), + ).rejects.toBeInstanceOf(ForbiddenError); + await expect( + presence.touch({ id: 'ana', name: 'Ana' }, 'nope', false), + ).rejects.toBeInstanceOf(NotFoundError); + }); +}); diff --git a/libs/api/tracker/application/src/lib/presence.service.ts b/libs/api/tracker/application/src/lib/presence.service.ts new file mode 100644 index 0000000..fec11a6 --- /dev/null +++ b/libs/api/tracker/application/src/lib/presence.service.ts @@ -0,0 +1,62 @@ +import { Injectable } from '@nestjs/common'; +import { NotFoundError } from '@task-stream/api/shared/domain'; +import { FindIssuesRepository } from './ports/find-issues.repository'; +import { PresenceEntry, PresenceStore } from './ports/presence.store'; +import { ReadAccess } from './queries/read-access'; + +/** A page beats every 10 s; three missed beats and it is gone. */ +export const PRESENCE_TTL_MS = 30_000; + +/** + * Who else is on a ticket right now, and whether they are editing it. Not + * history: kept in Redis for a few seconds, never in the event store. + */ +@Injectable() +export class PresenceService { + constructor( + private readonly store: PresenceStore, + private readonly findIssues: FindIssuesRepository, + private readonly access: ReadAccess, + ) {} + + /** + * I am on the ticket (editing or not): answers who else is there, the + * ones editing first. Only for people who can read it. + */ + async touch( + viewer: { id: string; name: string }, + issueId: string, + editing: boolean, + ): Promise { + await this.assertCanView(viewer.id, issueId); + const now = Date.now(); + await this.store.touch( + issueId, + { + user: { id: viewer.id, name: viewer.name }, + editing, + at: new Date(now).toISOString(), + }, + PRESENCE_TTL_MS, + ); + const here = await this.store.list(issueId, now - PRESENCE_TTL_MS); + return here + .filter((entry) => entry.user.id !== viewer.id) + .sort( + (a, b) => + Number(b.editing) - Number(a.editing) || + a.user.name.localeCompare(b.user.name, 'es'), + ); + } + + /** I left the ticket's page. */ + leave(viewerId: string, issueId: string): Promise { + return this.store.leave(issueId, viewerId); + } + + private async assertCanView(viewerId: string, issueId: string) { + const issue = await this.findIssues.findById(issueId); + if (!issue || issue.trashedAt) throw new NotFoundError('Issue', issueId); + await this.access.assertCanView(issue.projectId, viewerId); + } +} diff --git a/libs/api/tracker/application/src/lib/tracker-application.module.ts b/libs/api/tracker/application/src/lib/tracker-application.module.ts index 53bdb94..6bb553f 100644 --- a/libs/api/tracker/application/src/lib/tracker-application.module.ts +++ b/libs/api/tracker/application/src/lib/tracker-application.module.ts @@ -45,6 +45,7 @@ import { WEBHOOK_HANDLERS } from './webhooks/webhooks'; import { TransferService } from './transfer.service'; import { TRANSFER_HANDLERS } from './transfer/import-export'; import { AutomationsService } from './automations.service'; +import { PresenceService } from './presence.service'; import { AUTOMATION_HANDLERS } from './automations/automations'; import { CALENDAR_HANDLERS } from './calendar/calendar'; import { CalendarService } from './calendar.service'; @@ -79,6 +80,7 @@ const SERVICES = [ WebhooksService, TransferService, AutomationsService, + PresenceService, ProposalQuota, ]; diff --git a/libs/api/tracker/infrastructure/src/lib/redis/redis-presence.store.ts b/libs/api/tracker/infrastructure/src/lib/redis/redis-presence.store.ts new file mode 100644 index 0000000..00d1205 --- /dev/null +++ b/libs/api/tracker/infrastructure/src/lib/redis/redis-presence.store.ts @@ -0,0 +1,57 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { REDIS_CLIENT } from '@task-stream/api/shared/infrastructure'; +import { + PresenceEntry, + PresenceStore, +} from '@task-stream/api/tracker/application'; +import Redis from 'ioredis'; + +/** + * One hash per ticket (field: user id, value: the entry). The hash expires + * when nobody beats on it; stale fields are skipped and dropped on reading. + */ +@Injectable() +export class RedisPresenceStore extends PresenceStore { + constructor(@Inject(REDIS_CLIENT) private readonly redis: Redis) { + super(); + } + + private key(issueId: string): string { + return `task-stream:presence:${issueId}`; + } + + async touch( + issueId: string, + entry: PresenceEntry, + ttlMs: number, + ): Promise { + const key = this.key(issueId); + await this.redis + .multi() + .hset(key, entry.user.id, JSON.stringify(entry)) + .pexpire(key, ttlMs) + .exec(); + } + + async list(issueId: string, since: number): Promise { + const key = this.key(issueId); + const all = await this.redis.hgetall(key); + const fresh: PresenceEntry[] = []; + const stale: string[] = []; + for (const [userId, raw] of Object.entries(all)) { + try { + const entry = JSON.parse(raw) as PresenceEntry; + if (Date.parse(entry.at) >= since) fresh.push(entry); + else stale.push(userId); + } catch { + stale.push(userId); + } + } + if (stale.length) await this.redis.hdel(key, ...stale); + return fresh; + } + + async leave(issueId: string, userId: string): Promise { + await this.redis.hdel(this.key(issueId), userId); + } +} 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 54f6706..b76cb65 100644 --- a/libs/api/tracker/infrastructure/src/lib/tracker-infrastructure.module.ts +++ b/libs/api/tracker/infrastructure/src/lib/tracker-infrastructure.module.ts @@ -37,6 +37,7 @@ import { UpsertMaterializedTimelineRepository, UserDirectory, RateLimitStore, + PresenceStore, } from '@task-stream/api/tracker/application'; import { IdentityUserDirectory } from './identity/identity-user-directory'; import { @@ -68,6 +69,7 @@ import { PostgresReleaseIssuesRepository } from './postgres/release-issues.repos import { PostgresTrackerStreamsRepository } from './postgres/tracker-streams.repository'; import { RedisChangeFeed } from './realtime/redis-change-feed'; import { RedisRateLimitStore } from './redis/redis-rate-limit.store'; +import { RedisPresenceStore } from './redis/redis-presence.store'; import { MongoNotificationsRepository } from './mongo/notifications.repository'; import { MongoCalendarFeedsRepository } from './mongo/calendar-feeds.repository'; import { MongoPushSubscriptionsRepository } from './mongo/push-subscriptions.repository'; @@ -160,6 +162,7 @@ const PORTS = [ // Realtime → Redis pub/sub { provide: ChangeFeed, useClass: RedisChangeFeed }, { provide: RateLimitStore, useClass: RedisRateLimitStore }, + { provide: PresenceStore, useClass: RedisPresenceStore }, // Identity context (anti-corruption layer) { provide: UserDirectory, useClass: IdentityUserDirectory }, ]; diff --git a/libs/api/tracker/presenters/src/lib/graphql/issue.resolver.ts b/libs/api/tracker/presenters/src/lib/graphql/issue.resolver.ts index 1795696..c7d21ee 100644 --- a/libs/api/tracker/presenters/src/lib/graphql/issue.resolver.ts +++ b/libs/api/tracker/presenters/src/lib/graphql/issue.resolver.ts @@ -17,6 +17,7 @@ import { BlockersService, CommentsService, IssuesService, + PresenceService, WorkflowsService, } from '@task-stream/api/tracker/application'; import { @@ -48,6 +49,7 @@ import { IssueGql, IssuePageGql, IssueParentGql, + PresenceGql, } from './types'; @Resolver(() => IssueGql) @@ -57,8 +59,30 @@ export class IssueResolver { private readonly commentsService: CommentsService, private readonly workflows: WorkflowsService, private readonly blockers: BlockersService, + private readonly presence: PresenceService, ) {} + @Mutation(() => [PresenceGql], { + description: + 'I am on this ticket (send every ~10 s while the page is open): answers who else is, the ones editing first', + }) + touchPresence( + @CurrentUser() me: User, + @Args('issueId', { type: () => ID }) issueId: string, + @Args('editing', { defaultValue: false }) editing: boolean, + ) { + return this.presence.touch({ id: me.id, name: me.name }, issueId, editing); + } + + @Mutation(() => Boolean, { description: "I left this ticket's page" }) + async leavePresence( + @CurrentUser() me: User, + @Args('issueId', { type: () => ID }) issueId: string, + ) { + await this.presence.leave(me.id, issueId); + return true; + } + @OptionalAuth() @Query(() => IssueGql) issue( diff --git a/libs/api/tracker/presenters/src/lib/graphql/types.ts b/libs/api/tracker/presenters/src/lib/graphql/types.ts index 2198584..329ae46 100644 --- a/libs/api/tracker/presenters/src/lib/graphql/types.ts +++ b/libs/api/tracker/presenters/src/lib/graphql/types.ts @@ -1063,3 +1063,13 @@ export class ProjectInsightsGql { }) fields!: FieldBreakdownGql[]; } + +@ObjectType('Presence', { + description: 'Someone on a ticket right now (heartbeat in the last 30 s)', +}) +export class PresenceGql { + @Field(() => UserRefGql) user!: UserRefGql; + @Field({ description: 'Editing the ticket or writing a comment' }) + editing!: boolean; + @Field({ description: 'Last heartbeat' }) at!: string; +} diff --git a/libs/api/tracker/presenters/src/lib/http/dto/requests.dto.ts b/libs/api/tracker/presenters/src/lib/http/dto/requests.dto.ts index 874726e..bfc9539 100644 --- a/libs/api/tracker/presenters/src/lib/http/dto/requests.dto.ts +++ b/libs/api/tracker/presenters/src/lib/http/dto/requests.dto.ts @@ -1188,6 +1188,16 @@ export class SetIssueParentDto { parent!: string | null; } +export class PresenceBodyDto { + @ApiPropertyOptional({ + default: false, + description: 'Editing the ticket or writing a comment', + }) + @IsOptional() + @IsBoolean() + editing = false; +} + export class ChecklistItemBodyDto { @ApiProperty({ example: 'Actualizar el changelog', maxLength: 300 }) @IsString() 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 44436e3..b3da795 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 @@ -764,6 +764,14 @@ export class ProjectStatsDto { byAssignee!: CountByAssigneeDto[]; } +export class PresenceDto { + @ApiProperty({ type: UserRefDto }) user!: UserRefDto; + @ApiProperty({ description: 'Editing the ticket or writing a comment' }) + editing!: boolean; + @ApiProperty({ format: 'date-time', description: 'Last heartbeat' }) + at!: string; +} + export class ActivityDto { @ApiProperty() id!: string; @ApiProperty({ example: 'IssueStatusChanged' }) type!: string; 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 e802a3b..56da023 100644 --- a/libs/api/tracker/presenters/src/lib/http/issues.controller.ts +++ b/libs/api/tracker/presenters/src/lib/http/issues.controller.ts @@ -13,6 +13,7 @@ import { } from '@nestjs/common'; import { ApiCreatedResponse, + ApiNoContentResponse, ApiOkResponse, ApiOperation, ApiParam, @@ -31,6 +32,7 @@ import { import { CommentsService, IssuesService, + PresenceService, } from '@task-stream/api/tracker/application'; import { IssueLinkType } from '@task-stream/api/tracker/domain'; import { withoutNulls } from '../common/issue-input'; @@ -42,6 +44,7 @@ import { AddCommentDto, CreateIssueDto, IssueSearchQueryDto, + PresenceBodyDto, LinkIssueDto, LinkTypeParamDto, MyIssuesQueryDto, @@ -60,6 +63,7 @@ import { EpicDto, IssueDto, IssuePageDto, + PresenceDto, } from './dto/responses.dto'; import { viewerOf } from '../common/viewer'; import { toBulkChange } from '../common/bulk'; @@ -85,8 +89,40 @@ export class IssuesController { private readonly issues: IssuesService, private readonly comments: CommentsService, private readonly refs: Refs, + private readonly presence: PresenceService, ) {} + @Put('issues/:issue/presence') + @ISSUE + @ApiOperation({ + summary: 'I am on this ticket (heartbeat)', + description: + 'Send it every ~10 s while the page is open; entries fade after 30 s without one. Answers who else is there, the ones editing first.', + }) + @ApiOkResponse({ type: [PresenceDto] }) + @ApiReadErrors() + async touchPresence( + @CurrentUser() me: User, + @Param('issue') issue: string, + @Body() body: PresenceBodyDto, + ) { + return this.presence.touch( + { id: me.id, name: me.name }, + await this.refs.issueId(me.id, issue), + body.editing, + ); + } + + @Delete('issues/:issue/presence') + @HttpCode(204) + @ISSUE + @ApiOperation({ summary: "I left this ticket's page" }) + @ApiNoContentResponse() + @ApiReadErrors() + async leavePresence(@CurrentUser() me: User, @Param('issue') issue: string) { + await this.presence.leave(me.id, await this.refs.issueId(me.id, issue)); + } + @Put('issues/:issue/watch') @ISSUE @ApiOperation({ diff --git a/libs/web/tracker/application/src/index.ts b/libs/web/tracker/application/src/index.ts index ace6c64..c98ec80 100644 --- a/libs/web/tracker/application/src/index.ts +++ b/libs/web/tracker/application/src/index.ts @@ -11,6 +11,7 @@ export * from './lib/facades/inbox.facade'; export * from './lib/facades/epics.facade'; export * from './lib/facades/insights.facade'; export * from './lib/facades/my-views.facade'; +export * from './lib/facades/presence.facade'; export * from './lib/facades/backlog.facade'; export * from './lib/facades/list.facade'; export * from './lib/facades/propose.facade'; diff --git a/libs/web/tracker/application/src/lib/events/presence.events.ts b/libs/web/tracker/application/src/lib/events/presence.events.ts new file mode 100644 index 0000000..3b64e75 --- /dev/null +++ b/libs/web/tracker/application/src/lib/events/presence.events.ts @@ -0,0 +1,19 @@ +import { type } from '@ngrx/signals'; +import { eventGroup } from '@ngrx/signals/events'; +import { Presence } from '@task-stream/web/tracker/domain'; + +export const presencePageEvents = eventGroup({ + source: 'Issue Presence', + events: { + opened: type(), + editingChanged: type(), + left: type(), + }, +}); + +export const presenceApiEvents = eventGroup({ + source: 'Presence API', + events: { + seen: type(), + }, +}); diff --git a/libs/web/tracker/application/src/lib/facades/presence.facade.ts b/libs/web/tracker/application/src/lib/facades/presence.facade.ts new file mode 100644 index 0000000..896c6fc --- /dev/null +++ b/libs/web/tracker/application/src/lib/facades/presence.facade.ts @@ -0,0 +1,28 @@ +import { inject, Injectable, Provider } from '@angular/core'; +import { injectDispatch } from '@ngrx/signals/events'; +import { presencePageEvents } from '../events/presence.events'; +import { PresenceStore } from '../stores/presence.store'; + +/** Who else is on the ticket's page, and whether they are editing it. */ +@Injectable() +export class PresenceFacade { + private readonly store = inject(PresenceStore); + private readonly dispatch = injectDispatch(presencePageEvents); + + /** The others, the ones editing first. */ + readonly others = this.store.others; + + /** I am on this ticket: beats until the page closes. */ + open(issueId: string): void { + if (this.store.issueId() !== issueId) this.dispatch.opened(issueId); + } + + setEditing(editing: boolean): void { + this.dispatch.editingChanged(editing); + } +} + +/** Store + facade of a ticket's presence, for the ticket's page. */ +export function providePresenceFacade(): Provider[] { + return [PresenceStore, PresenceFacade]; +} diff --git a/libs/web/tracker/application/src/lib/stores/presence.store.ts b/libs/web/tracker/application/src/lib/stores/presence.store.ts new file mode 100644 index 0000000..f2a0307 --- /dev/null +++ b/libs/web/tracker/application/src/lib/stores/presence.store.ts @@ -0,0 +1,93 @@ +import { inject } from '@angular/core'; +import { signalStore, withHooks, withState } from '@ngrx/signals'; +import { + Events, + on, + withEventHandlers, + withReducer, +} from '@ngrx/signals/events'; +import { Presence, PresenceGateway } from '@task-stream/web/tracker/domain'; +import { + catchError, + combineLatest, + distinctUntilChanged, + EMPTY, + map, + mergeMap, + pairwise, + startWith, + switchMap, + takeUntil, + timer, +} from 'rxjs'; +import { + presenceApiEvents, + presencePageEvents, +} from '../events/presence.events'; + +/** A heartbeat every so often; the API forgets a page after 30 s without one. */ +export const PRESENCE_BEAT_MS = 10_000; + +interface PresenceState { + issueId: string | null; + others: Presence[]; +} + +/** + * Who else is on the ticket's page. Beats while the page is open (and at + * once when I start or stop editing); says goodbye when it closes. Scoped + * to the ticket's page. + */ +export const PresenceStore = signalStore( + withState({ issueId: null, others: [] }), + withReducer( + on(presencePageEvents.opened, ({ payload }) => ({ + issueId: payload, + others: [], + })), + on(presenceApiEvents.seen, ({ payload }) => ({ others: payload })), + on(presencePageEvents.left, () => ({ issueId: null, others: [] })), + ), + withEventHandlers( + (store, events = inject(Events), presence = inject(PresenceGateway)) => ({ + beat$: events.on(presencePageEvents.opened).pipe( + switchMap(({ payload: issueId }) => + combineLatest([ + events.on(presencePageEvents.editingChanged).pipe( + map(({ payload }) => payload), + startWith(false), + distinctUntilChanged(), + ), + timer(0, PRESENCE_BEAT_MS), + ]).pipe( + switchMap(([editing]) => + // A missed beat is no error worth showing: the next one retries. + presence.touch(issueId, editing).pipe(catchError(() => EMPTY)), + ), + map((others) => presenceApiEvents.seen(others)), + takeUntil(events.on(presencePageEvents.left)), + ), + ), + ), + // Moving to another ticket in the same page: goodbye to the previous one. + moved$: events.on(presencePageEvents.opened).pipe( + map(({ payload }) => payload), + pairwise(), + mergeMap(([previous]) => + presence.leave(previous).pipe(catchError(() => EMPTY)), + ), + switchMap(() => EMPTY), + ), + }), + ), + withHooks((store, presence = inject(PresenceGateway)) => ({ + onDestroy() { + const issueId = store.issueId(); + if (issueId) + presence + .leave(issueId) + .pipe(catchError(() => EMPTY)) + .subscribe(); + }, + })), +); diff --git a/libs/web/tracker/domain/src/lib/models/models.ts b/libs/web/tracker/domain/src/lib/models/models.ts index cbeb274..675a70f 100644 --- a/libs/web/tracker/domain/src/lib/models/models.ts +++ b/libs/web/tracker/domain/src/lib/models/models.ts @@ -197,6 +197,15 @@ export interface Issue extends IssueSummary { createdAt: string; } +/** Someone else on a ticket's page right now. */ +export interface Presence { + user: UserRef; + /** Editing the ticket or writing a comment. */ + editing: boolean; + /** Last heartbeat. */ + at: string; +} + /** A commit, branch or pull request that mentions a ticket. */ export interface DevLink { kind: 'COMMIT' | 'BRANCH' | 'PULL_REQUEST'; diff --git a/libs/web/tracker/domain/src/lib/ports/gateways.ts b/libs/web/tracker/domain/src/lib/ports/gateways.ts index 4905a1e..cc3a156 100644 --- a/libs/web/tracker/domain/src/lib/ports/gateways.ts +++ b/libs/web/tracker/domain/src/lib/ports/gateways.ts @@ -25,6 +25,7 @@ import { Release, ReleaseBurnup, SavedFilter, + Presence, MyView, MyViewCriteria, SavedFilterCriteria, @@ -430,3 +431,9 @@ export abstract class TrackerRealtimeGateway { */ abstract reconnects(): Observable; } + +/** Who is on a ticket's page: a heartbeat answers who else is. */ +export abstract class PresenceGateway { + abstract touch(issueId: string, editing: boolean): Observable; + abstract leave(issueId: string): Observable; +} diff --git a/libs/web/tracker/infrastructure/src/lib/adapters/graphql-presence.gateway.ts b/libs/web/tracker/infrastructure/src/lib/adapters/graphql-presence.gateway.ts new file mode 100644 index 0000000..2fc63c2 --- /dev/null +++ b/libs/web/tracker/infrastructure/src/lib/adapters/graphql-presence.gateway.ts @@ -0,0 +1,25 @@ +import { inject, Injectable } from '@angular/core'; +import { Presence, PresenceGateway } from '@task-stream/web/tracker/domain'; +import { map, Observable } from 'rxjs'; +import { + LeavePresenceDocument, + TouchPresenceDocument, +} from '../graphql/generated'; +import { GraphqlOperations } from './graphql-operations'; + +@Injectable() +export class GraphqlPresenceGateway extends PresenceGateway { + private readonly gql = inject(GraphqlOperations); + + touch(issueId: string, editing: boolean): Observable { + return this.gql + .mutate(TouchPresenceDocument, { issueId, editing }) + .pipe(map((d) => d.touchPresence)); + } + + leave(issueId: string): Observable { + return this.gql + .mutate(LeavePresenceDocument, { issueId }) + .pipe(map(() => undefined)); + } +} diff --git a/libs/web/tracker/infrastructure/src/lib/graphql/generated.ts b/libs/web/tracker/infrastructure/src/lib/graphql/generated.ts index ff7dd8c..dcf0a5a 100644 --- a/libs/web/tracker/infrastructure/src/lib/graphql/generated.ts +++ b/libs/web/tracker/infrastructure/src/lib/graphql/generated.ts @@ -1546,6 +1546,25 @@ export type NotificationAddedSubscription = { }; }; +export type TouchPresenceMutationVariables = Exact<{ + issueId: string; + editing: boolean; +}>; + +export type TouchPresenceMutation = { + touchPresence: Array<{ + editing: boolean; + at: string; + user: { id: string; name: string }; + }>; +}; + +export type LeavePresenceMutationVariables = Exact<{ + issueId: string; +}>; + +export type LeavePresenceMutation = { leavePresence: boolean }; + export type MyProjectsQueryVariables = Exact<{ [key: string]: never }>; export type MyProjectsQuery = { @@ -9536,6 +9555,153 @@ export const NotificationAddedDocument = { NotificationAddedSubscription, NotificationAddedSubscriptionVariables >; +export const TouchPresenceDocument = { + kind: 'Document', + definitions: [ + { + kind: 'OperationDefinition', + operation: 'mutation', + name: { kind: 'Name', value: 'TouchPresence' }, + variableDefinitions: [ + { + kind: 'VariableDefinition', + variable: { + kind: 'Variable', + name: { kind: 'Name', value: 'issueId' }, + }, + type: { + kind: 'NonNullType', + type: { kind: 'NamedType', name: { kind: 'Name', value: 'ID' } }, + }, + }, + { + kind: 'VariableDefinition', + variable: { + kind: 'Variable', + name: { kind: 'Name', value: 'editing' }, + }, + type: { + kind: 'NonNullType', + type: { + kind: 'NamedType', + name: { kind: 'Name', value: 'Boolean' }, + }, + }, + }, + ], + selectionSet: { + kind: 'SelectionSet', + selections: [ + { + kind: 'Field', + name: { kind: 'Name', value: 'touchPresence' }, + arguments: [ + { + kind: 'Argument', + name: { kind: 'Name', value: 'issueId' }, + value: { + kind: 'Variable', + name: { kind: 'Name', value: 'issueId' }, + }, + }, + { + kind: 'Argument', + name: { kind: 'Name', value: 'editing' }, + value: { + kind: 'Variable', + name: { kind: 'Name', value: 'editing' }, + }, + }, + ], + selectionSet: { + kind: 'SelectionSet', + selections: [ + { + kind: 'Field', + name: { kind: 'Name', value: 'user' }, + selectionSet: { + kind: 'SelectionSet', + selections: [ + { + kind: 'FragmentSpread', + name: { kind: 'Name', value: 'UserRefFields' }, + }, + ], + }, + }, + { kind: 'Field', name: { kind: 'Name', value: 'editing' } }, + { kind: 'Field', name: { kind: 'Name', value: 'at' } }, + ], + }, + }, + ], + }, + }, + { + kind: 'FragmentDefinition', + name: { kind: 'Name', value: 'UserRefFields' }, + typeCondition: { + kind: 'NamedType', + name: { kind: 'Name', value: 'UserRef' }, + }, + selectionSet: { + kind: 'SelectionSet', + selections: [ + { kind: 'Field', name: { kind: 'Name', value: 'id' } }, + { kind: 'Field', name: { kind: 'Name', value: 'name' } }, + ], + }, + }, + ], +} as unknown as DocumentNode< + TouchPresenceMutation, + TouchPresenceMutationVariables +>; +export const LeavePresenceDocument = { + kind: 'Document', + definitions: [ + { + kind: 'OperationDefinition', + operation: 'mutation', + name: { kind: 'Name', value: 'LeavePresence' }, + variableDefinitions: [ + { + kind: 'VariableDefinition', + variable: { + kind: 'Variable', + name: { kind: 'Name', value: 'issueId' }, + }, + type: { + kind: 'NonNullType', + type: { kind: 'NamedType', name: { kind: 'Name', value: 'ID' } }, + }, + }, + ], + selectionSet: { + kind: 'SelectionSet', + selections: [ + { + kind: 'Field', + name: { kind: 'Name', value: 'leavePresence' }, + arguments: [ + { + kind: 'Argument', + name: { kind: 'Name', value: 'issueId' }, + value: { + kind: 'Variable', + name: { kind: 'Name', value: 'issueId' }, + }, + }, + ], + }, + ], + }, + }, + ], +} as unknown as DocumentNode< + LeavePresenceMutation, + LeavePresenceMutationVariables +>; export const MyProjectsDocument = { kind: 'Document', definitions: [ diff --git a/libs/web/tracker/infrastructure/src/lib/graphql/presence.graphql b/libs/web/tracker/infrastructure/src/lib/graphql/presence.graphql new file mode 100644 index 0000000..1c6d8c4 --- /dev/null +++ b/libs/web/tracker/infrastructure/src/lib/graphql/presence.graphql @@ -0,0 +1,13 @@ +mutation TouchPresence($issueId: ID!, $editing: Boolean!) { + touchPresence(issueId: $issueId, editing: $editing) { + user { + ...UserRefFields + } + editing + at + } +} + +mutation LeavePresence($issueId: ID!) { + leavePresence(issueId: $issueId) +} diff --git a/libs/web/tracker/infrastructure/src/lib/tracker-infrastructure.providers.ts b/libs/web/tracker/infrastructure/src/lib/tracker-infrastructure.providers.ts index b681f0d..914912e 100644 --- a/libs/web/tracker/infrastructure/src/lib/tracker-infrastructure.providers.ts +++ b/libs/web/tracker/infrastructure/src/lib/tracker-infrastructure.providers.ts @@ -10,6 +10,7 @@ import { IssuesGateway, NotificationsGateway, CalendarGateway, + PresenceGateway, PushDevice, ProjectsGateway, ReleasesGateway, @@ -31,6 +32,7 @@ import { import { GraphqlIssuesGateway } from './adapters/graphql-issues.gateway'; import { GraphqlNotificationsGateway } from './adapters/graphql-notifications.gateway'; import { GraphqlCalendarGateway } from './adapters/graphql-calendar.gateway'; +import { GraphqlPresenceGateway } from './adapters/graphql-presence.gateway'; import { BrowserPushDevice } from './adapters/browser-push.device'; import { HttpAttachmentsGateway } from './adapters/http-attachments.gateway'; import { HttpProposalsGateway } from './adapters/http-proposals.gateway'; @@ -56,6 +58,7 @@ export function provideTrackerInfrastructure(): EnvironmentProviders { { provide: UsersGateway, useClass: GraphqlUsersGateway }, { provide: NotificationsGateway, useClass: GraphqlNotificationsGateway }, { provide: CalendarGateway, useClass: GraphqlCalendarGateway }, + { provide: PresenceGateway, useClass: GraphqlPresenceGateway }, { provide: PushDevice, useClass: BrowserPushDevice }, { provide: TrackerRealtimeGateway, useClass: GraphqlRealtimeGateway }, { provide: SavedFiltersGateway, useClass: GraphqlSavedFiltersGateway }, 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 0aecdcd..5d8cbcf 100644 --- a/libs/web/tracker/presenters/src/lib/pages/issue.page.scss +++ b/libs/web/tracker/presenters/src/lib/pages/issue.page.scss @@ -551,3 +551,19 @@ a.ts-key { color: var(--ts-muted); font-size: 0.8125rem; } + +.ts-presence { + display: flex; + align-items: center; + gap: 0.5rem; + margin: 0.375rem 0 0.25rem; + color: var(--ts-ink-soft); + font-size: 0.8125rem; +} + +.ts-presence__dot { + width: 0.5rem; + height: 0.5rem; + border-radius: 50%; + background: var(--ts-c-green, #2e9e6a); +} diff --git a/libs/web/tracker/presenters/src/lib/pages/issue.page.ts b/libs/web/tracker/presenters/src/lib/pages/issue.page.ts index e74f326..7fd30a4 100644 --- a/libs/web/tracker/presenters/src/lib/pages/issue.page.ts +++ b/libs/web/tracker/presenters/src/lib/pages/issue.page.ts @@ -9,6 +9,7 @@ import { input, signal, TemplateRef, + untracked, viewChild, } from '@angular/core'; import { toObservable, toSignal } from '@angular/core/rxjs-interop'; @@ -38,6 +39,8 @@ import { IssueFacade, ProjectFacade, provideIssueFacade, + PresenceFacade, + providePresenceFacade, } from '@task-stream/web/tracker/application'; import { Attachment, @@ -105,7 +108,7 @@ const MAX_FILE_BYTES = 25 * 1024 * 1024; PiTextFieldComponent, PiTextareaComponent, ], - providers: [provideIssueFacade()], + providers: [provideIssueFacade(), providePresenceFacade()], styleUrl: './issue.page.scss', template: ` ← Tablero @@ -152,6 +155,12 @@ const MAX_FILE_BYTES = 25 * 1024 * 1024; } + @if (presence.others().length) { +

+ + {{ presenceText() }} +

+ }

{{ issue.title }}

@@ -1273,8 +1282,47 @@ export class IssuePage { required(p.body), ); + /* Presence: who else is here; I count as editing while a form is open. */ + protected readonly presence = inject(PresenceFacade); + private readonly editingInDrawer = signal(false); + protected readonly presenceText = computed(() => { + const others = this.presence.others(); + const editing = others.filter((p) => p.editing).map((p) => p.user.name); + const looking = others.filter((p) => !p.editing).map((p) => p.user.name); + const list = (names: string[]) => + names.length > 1 + ? `${names.slice(0, -1).join(', ')} y ${names[names.length - 1]}` + : names.join(''); + return [ + editing.length + ? `${list(editing)} ${editing.length > 1 ? 'están editando' : 'está editando'}` + : '', + looking.length + ? `${list(looking)} ${looking.length > 1 ? 'también lo están viendo' : 'también lo está viendo'}` + : '', + ] + .filter(Boolean) + .join(' · '); + }); + constructor() { effect(() => this.facade.open(this.issueKey())); + effect(() => { + const id = this.facade.issue()?.id; + if (id) untracked(() => this.presence.open(id)); + }); + effect(() => { + const editing = this.editingInDrawer() || !!this.commentBody().trim(); + untracked(() => this.presence.setEditing(editing)); + }); + } + + /** While a form is open in the drawer, I am editing. */ + private trackEditing(ref: { + afterClosed(): { subscribe(f: () => void): unknown }; + }): void { + this.editingInDrawer.set(true); + ref.afterClosed().subscribe(() => this.editingInDrawer.set(false)); } protected statusLabel = (status: string) => @@ -1492,7 +1540,9 @@ export class IssuePage { protected openEditComment(comment: Comment): void { this.editingComment = comment.id; this.commentEditModel.set({ body: comment.body }); - this.drawer.open('Editar comentario', this.commentEditForm()); + this.trackEditing( + this.drawer.open('Editar comentario', this.commentEditForm()), + ); } protected saveComment(): void { @@ -1515,7 +1565,9 @@ export class IssuePage { labels: issue.labels.join(', '), figmaUrl: issue.figmaUrl ?? '', }); - this.drawer.open(`Editar ${issue.key}`, this.editForm(), '36rem'); + this.trackEditing( + this.drawer.open(`Editar ${issue.key}`, this.editForm(), '36rem'), + ); } protected saveEdit(): void {