From dfa42a8a1ae1eee61a03ad20eaf44b01f85003be Mon Sep 17 00:00:00 2001 From: Josep Piguillem Date: Sat, 17 Oct 2026 10:15:28 +0200 Subject: [PATCH] feat: pinned tickets, at hand on the home page and in search MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Each person pins up to 20 tickets from the ticket's page («Fijar»). They show as a strip under the projects on the home page and in the search palette before typing. Private and without history, like the views of «my work» (Mongo `pinned_issues`); tickets in the trash or out of reach are left out, and when the list is full the unreadable ones are dropped first. API: GET /api/me/pins, PUT/DELETE /api/issues/{issue}/pin; GraphQL myPins, pinIssue, unpinIssue and Issue.pinned. Co-Authored-By: Claude Opus 5.5 --- apps/api/openapi.json | 261 ++++++++++++++++ apps/api/schema.gql | 16 + apps/mcp/src/app/api/taskstream.schema.ts | 244 +++++++++++++++ docs/01-vision-general.md | 2 +- docs/03-api.md | 64 ++-- docs/04-cliente-web.md | 4 +- libs/api/tracker/application/src/index.ts | 3 + .../application/src/lib/pins.service.ts | 31 ++ .../src/lib/ports/pins.repository.ts | 12 + .../src/lib/saved-filters/pins.spec.ts | 142 +++++++++ .../application/src/lib/saved-filters/pins.ts | 150 ++++++++++ .../src/lib/tracker-application.module.ts | 4 + .../read-models/saved-filter.read-model.ts | 7 + .../src/lib/mongo/pins.repository.ts | 48 +++ .../src/lib/mongo/read-model.schemas.ts | 14 + .../src/lib/tracker-infrastructure.module.ts | 3 + .../src/lib/graphql/issue.resolver.ts | 41 +++ .../src/lib/http/issues.controller.ts | 37 +++ libs/web/shared/ui/src/lib/icons.ts | 1 + .../src/lib/events/issue.events.ts | 1 + .../src/lib/facades/issue.facade.ts | 7 + .../application/src/lib/stores/issue.store.ts | 14 + .../tracker/domain/src/lib/models/models.ts | 2 + .../tracker/domain/src/lib/ports/gateways.ts | 4 + .../lib/adapters/graphql-issues.gateway.ts | 16 + .../src/lib/graphql/fragments.graphql | 1 + .../src/lib/graphql/generated.ts | 281 ++++++++++++++++++ .../src/lib/graphql/issues.graphql | 14 + .../presenters/src/lib/pages/issue.page.ts | 15 + .../presenters/src/lib/pages/projects.page.ts | 4 + .../presenters/src/lib/ui/command-palette.ts | 43 ++- .../presenters/src/lib/ui/home/pinned.ts | 172 +++++++++++ 32 files changed, 1612 insertions(+), 46 deletions(-) create mode 100644 libs/api/tracker/application/src/lib/pins.service.ts create mode 100644 libs/api/tracker/application/src/lib/ports/pins.repository.ts create mode 100644 libs/api/tracker/application/src/lib/saved-filters/pins.spec.ts create mode 100644 libs/api/tracker/application/src/lib/saved-filters/pins.ts create mode 100644 libs/api/tracker/infrastructure/src/lib/mongo/pins.repository.ts create mode 100644 libs/web/tracker/presenters/src/lib/ui/home/pinned.ts diff --git a/apps/api/openapi.json b/apps/api/openapi.json index c419afa..9ba892c 100644 --- a/apps/api/openapi.json +++ b/apps/api/openapi.json @@ -2515,6 +2515,267 @@ ] } }, + "/api/me/pins": { + "get": { + "description": "Across my projects; tickets in the trash or out of my reach are left out.", + "operationId": "IssuesController_myPins", + "parameters": [], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/IssueDto" + } + } + } + } + }, + "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": "My pinned tickets, most recently pinned first", + "tags": [ + "Issues" + ] + } + }, + "/api/issues/{issue}/pin": { + "put": { + "description": "Private to me; at most 20. Pinning it again changes nothing.", + "operationId": "IssuesController_pin", + "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" + } + } + } + }, + "409": { + "description": "Someone changed it at the same time: retry", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorDto" + } + } + } + }, + "422": { + "description": "Business rule (e.g. a transition the workflow forbids)", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorDto" + } + } + } + } + }, + "security": [ + { + "pireon-oauth": [] + }, + { + "pireon": [] + } + ], + "summary": "Pin a ticket: keep it at hand on my home page and in search", + "tags": [ + "Issues" + ] + }, + "delete": { + "operationId": "IssuesController_unpin", + "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" + } + } + } + }, + "409": { + "description": "Someone changed it at the same time: retry", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorDto" + } + } + } + }, + "422": { + "description": "Business rule (e.g. a transition the workflow forbids)", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorDto" + } + } + } + } + }, + "security": [ + { + "pireon-oauth": [] + }, + { + "pireon": [] + } + ], + "summary": "Unpin a ticket", + "tags": [ + "Issues" + ] + } + }, "/api/issues/{issue}/watch": { "put": { "description": "Followers are told when it moves, is reassigned or gets a comment. Commenting follows it too.", diff --git a/apps/api/schema.gql b/apps/api/schema.gql index f8548ee..3b3c256 100644 --- a/apps/api/schema.gql +++ b/apps/api/schema.gql @@ -662,6 +662,9 @@ type Issue { """The epic or ticket this one breaks down""" parent: IssueParent + + """Whether I pinned it (false without a token)""" + pinned: Boolean! priority: IssuePriority! projectId: ID! projectKey: String! @@ -973,6 +976,11 @@ type Mutation { """Mark these as read, or all of them when `ids` is omitted""" markNotificationsRead(ids: [ID!]): Boolean! + """ + Pin a ticket: keep it at hand on my home page and in search (private, at most 20) + """ + pinIssue(issueId: ID!): Boolean! + """ Ship the release. Fails while it has unfinished tickets unless force = true. """ @@ -1046,6 +1054,9 @@ type Mutation { unarchiveProject(projectId: ID!): CommandResult! unlinkIssues(input: LinkIssuesInput!): CommandResult! + """Unpin a ticket""" + unpinIssue(issueId: ID!): Boolean! + """The link stops working""" unshareDashboard(projectId: ID!): Boolean! @@ -1532,6 +1543,11 @@ type Query { """My notifications, newest first, and how many are unread""" myNotifications(limit: Int! = 30, unreadOnly: Boolean! = false): MyNotifications! + """ + My pinned tickets, most recently pinned first (trashed or out of reach left out) + """ + myPins: [Issue!]! + """Projects where I am a member""" myProjects: [Project!]! diff --git a/apps/mcp/src/app/api/taskstream.schema.ts b/apps/mcp/src/app/api/taskstream.schema.ts index ab8ac41..3d9779e 100644 --- a/apps/mcp/src/app/api/taskstream.schema.ts +++ b/apps/mcp/src/app/api/taskstream.schema.ts @@ -461,6 +461,47 @@ export interface paths { patch?: never; trace?: never; }; + "/api/me/pins": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * My pinned tickets, most recently pinned first + * @description Across my projects; tickets in the trash or out of my reach are left out. + */ + get: operations["IssuesController_myPins"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/issues/{issue}/pin": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + /** + * Pin a ticket: keep it at hand on my home page and in search + * @description Private to me; at most 20. Pinning it again changes nothing. + */ + put: operations["IssuesController_pin"]; + post?: never; + /** Unpin a ticket */ + delete: operations["IssuesController_unpin"]; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/issues/{issue}/watch": { parameters: { query?: never; @@ -5277,6 +5318,209 @@ export interface operations { }; }; }; + IssuesController_myPins: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["IssueDto"][]; + }; + }; + /** @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_pin: { + 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"]; + }; + }; + /** @description Someone changed it at the same time: retry */ + 409: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiErrorDto"]; + }; + }; + /** @description Business rule (e.g. a transition the workflow forbids) */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiErrorDto"]; + }; + }; + }; + }; + IssuesController_unpin: { + 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"]; + }; + }; + /** @description Someone changed it at the same time: retry */ + 409: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiErrorDto"]; + }; + }; + /** @description Business rule (e.g. a transition the workflow forbids) */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiErrorDto"]; + }; + }; + }; + }; IssuesController_watch: { parameters: { query?: never; diff --git a/docs/01-vision-general.md b/docs/01-vision-general.md index 4eca512..ed706b1 100644 --- a/docs/01-vision-general.md +++ b/docs/01-vision-general.md @@ -24,7 +24,7 @@ | 📋 Backlog | Lo pendiente de empezar, en el orden en que se va a coger; se reordena arrastrando y el tablero sigue ese orden | | 🗑️ Papelera | Borrar un ticket lo manda a la papelera (quien lo abrió o un admin); se recupera durante 30 días y luego se borra del todo | | ☑️ En bloque | Selecciona varios tickets del backlog y cámbiales a la vez estado, persona, sprint, prioridad o etiqueta | -| ⌨️ Atajos y búsqueda | Ctrl/⌘ K o / busca tickets y proyectos de todos tus proyectos; `g b`, `g k`, `g s`, `g r` navegan, `c` crea, `e` edita, `?` los lista | +| ⌨️ Atajos y búsqueda | Ctrl/⌘ K o / busca tickets y proyectos de todos tus proyectos (antes de escribir, tus tickets fijados); `g b`, `g k`, `g s`, `g r` navegan, `c` crea, `e` edita, `?` los lista | | 🗺️ Hoja de ruta | Las épicas en el tiempo (fecha de inicio y límite), con los sprints y las releases como referencias y flechas entre las que dependen unas de otras | | 🗳️ Propuestas y votos | Un proyecto público puede abrir un formulario para que cualquiera, sin cuenta, reporte un error o proponga una idea; quien tenga sesión vota lo que quiere | | 🌐 Web del colectivo | La página de cada proyecto en pireon.dev enseña su hoja de ruta, versiones, sprint y tickets para recién llegados (`good-first-issue`) | diff --git a/docs/03-api.md b/docs/03-api.md index 4d91211..6427fc4 100644 --- a/docs/03-api.md +++ b/docs/03-api.md @@ -396,7 +396,11 @@ eventos `IssueWatchedEvent`/`IssueUnwatchedEvent`), y quien lo abre, quien lo ti sigue un ticket (`WATCHED`) sabe cuándo cambia de estado, se asigna, se comenta, cambia su prioridad o su fecha límite, o entra o sale de un sprint; las ediciones menores (título, etiquetas…) no avisan. «Los que sigo»: `watching` en `myIssues` / `GET /api/me/issues` y en la búsqueda de un proyecto (`issues(filter: { watching: true })`, -`?watching=true`; sin sesión, ninguno). El id es +`?watching=true`; sin sesión, ninguno). **Fijar**: cada persona fija hasta 20 tickets para tenerlos a mano en el +inicio y en la búsqueda (`pinIssue`/`unpinIssue`, `myPins`, `Issue.pinned`; `GET /api/me/pins`, +`PUT/DELETE /api/issues/{issue}/pin`). Son privados y sin historial (colección `pinned_issues`, como las vistas de «Mi +trabajo»); los que están en la papelera o fuera de su alcance no se listan, y al llegar a 20 se olvidan primero los +que ya no puede leer. El id es `::`, así que reentregar un evento no duplica avisos. Llegan en vivo por la subscription `notificationAdded` y, si la instalación tiene `SMTP_URL`, también por correo a quien lo active (`email`, desactivado por defecto); un correo que falla se registra y no bloquea el stream. @@ -507,17 +511,17 @@ que funcionan también sin token. Todas devuelven `CommandResult { id, key, version }`. -| Ámbito | Mutations | -| ----------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| Proyectos | `createProject`, `updateProject`, `updateProjectSettings`, `deleteProject(confirmKey)`, `addProjectMember`, `changeProjectMemberRole`, `removeProjectMember` | -| Tickets | `createIssue`, `updateIssue`, `changeIssueStatus`, `assignIssue`, `linkIssues`, `unlinkIssues`, `setIssueRelease`, `setIssueParent`, `rankIssue`, `addChecklistItem`, `updateChecklistItem`, `removeChecklistItem`, `watchIssue`, `unwatchIssue`, `removeAttachment` (subir: por REST), `duplicateIssue`, `voteIssue` | -| Comentarios | `addComment(replyTo)`, `editComment`, `deleteComment`, `reactToComment` | -| Webhooks | `createWebhook` (devuelve el secreto), `updateWebhook`, `deleteWebhook`, `testWebhook` | -| Git | `addGitRepository` (devuelve su secreto), `rotateGitRepositorySecret`, `removeGitRepository` | -| Sprints | `createSprint`, `updateSprint`, `startSprint`, `closeSprint(carryOverToSprintId)`, `deleteSprint`, `setIssueSprint` | -| Vistas | `saveFilter(input)` (devuelve el id), `deleteSavedFilter(id)`, `GET/POST /api/me/views`, `DELETE /api/me/views/{id}` (mis vistas de «Mi trabajo») | -| Avisos | `markNotificationsRead(ids)`, `updateNotificationSettings(email)` | -| Releases | `createRelease`, `updateRelease`, `publishRelease(force)`, `writeReleaseNotes`, `archiveRelease` | +| Ámbito | Mutations | +| ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| Proyectos | `createProject`, `updateProject`, `updateProjectSettings`, `deleteProject(confirmKey)`, `addProjectMember`, `changeProjectMemberRole`, `removeProjectMember` | +| Tickets | `createIssue`, `updateIssue`, `changeIssueStatus`, `assignIssue`, `linkIssues`, `unlinkIssues`, `setIssueRelease`, `setIssueParent`, `rankIssue`, `addChecklistItem`, `updateChecklistItem`, `removeChecklistItem`, `watchIssue`, `unwatchIssue`, `pinIssue`, `unpinIssue` (devuelven `true`), `removeAttachment` (subir: por REST), `duplicateIssue`, `voteIssue` | +| Comentarios | `addComment(replyTo)`, `editComment`, `deleteComment`, `reactToComment` | +| Webhooks | `createWebhook` (devuelve el secreto), `updateWebhook`, `deleteWebhook`, `testWebhook` | +| Git | `addGitRepository` (devuelve su secreto), `rotateGitRepositorySecret`, `removeGitRepository` | +| Sprints | `createSprint`, `updateSprint`, `startSprint`, `closeSprint(carryOverToSprintId)`, `deleteSprint`, `setIssueSprint` | +| Vistas | `saveFilter(input)` (devuelve el id), `deleteSavedFilter(id)`, `GET/POST /api/me/views`, `DELETE /api/me/views/{id}` (mis vistas de «Mi trabajo») | +| Avisos | `markNotificationsRead(ids)`, `updateNotificationSettings(email)` | +| Releases | `createRelease`, `updateRelease`, `publishRelease(force)`, `writeReleaseNotes`, `archiveRelease` | ### Subscriptions @@ -714,24 +718,24 @@ Lo que cada usuario puede ver o cambiar depende de su rol en cada proyecto, como Proyectos y tickets se pueden indicar por **id o por clave** (`WEB`, `WEB-12`). -| Recurso | Rutas | -| ----------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| Usuarios | `GET /api/users/me`, `GET /api/users?search=`, `GET /api/users/{id}` | -| Proyectos | `GET/POST /api/projects`, `GET/PATCH/DELETE /api/projects/{project}` (`DELETE …?confirm=CLAVE`), `POST …/archive`, `POST …/unarchive`, `PUT …/settings`, `…/board`, `…/epics`, `…/stats`, `…/analytics?days=`, `…/insights`, `…/activity`, `…/members` (POST, PATCH, DELETE) | -| Tickets | `GET /api/me/home` (portada), `GET /api/me/portfolio` (cartera), `GET /api/me/issues` (mi trabajo; filtros `project`, `priority`, `bugsOnly`, `overdue`), `GET/POST /api/projects/{project}/issues`, `GET/PATCH /api/issues/{issue}`, `PUT …/status`, `PUT …/assignee`, `PUT …/release`, `PUT/DELETE …/watch`, `PUT/DELETE …/presence`, `PUT …/parent`, `PUT …/rank`, `POST …/checklist`, `PATCH/DELETE …/checklist/{item}`, `POST/DELETE …/links`, `GET/POST …/comments`, `GET …/history`, `POST /api/issues/{issue}/duplicate`, `PUT/DELETE …/vote` | -| Comentarios | `PATCH/DELETE /api/comments/{commentId}`, `POST …/reactions` | -| Sprints | `GET/POST /api/projects/{project}/sprints`, `GET/PATCH/DELETE /api/sprints/{sprint}`, `POST …/start`, `POST …/close`, `GET …/progress`, `…/burndown`, `…/issues`, `PUT /api/issues/{issue}/sprint` | -| Vistas | `GET/POST /api/projects/{project}/filters`, `DELETE /api/filters/{id}` | -| Avisos | `GET /api/me/notifications`, `POST /api/me/notifications/read`, `GET/PUT /api/me/notification-settings` | -| Adjuntos | `POST /api/issues/{issue}/attachments` (multipart: `file` y opcional `commentId`), `GET/DELETE /api/issues/{issue}/attachments/{id}` | -| Releases | `GET /api/me/releases` (próximas), `GET/POST /api/projects/{project}/releases`, `GET/PATCH /api/releases/{id}`, `PUT …/notes`, `GET …/notes/draft`, `…/progress`, `…/burnup`, `…/issues`, `POST …/publish`, `POST …/archive` | -| Explorar | `GET /api/explore/projects` (proyectos públicos, sin token) | -| Compartir | `GET/PUT/DELETE /api/projects/{project}/share` (enlace para clientes), `GET /api/shared/dashboards/{token}` (lo que enseña, sin token) | -| Importar | `GET /api/projects/{project}/export.csv`, `POST /api/projects/{project}/import?dryRun=` (multipart `file`) | -| Webhooks | `GET /api/webhook-events`, `GET/POST /api/projects/{project}/webhooks`, `PATCH/DELETE /api/outgoing-webhooks/{id}`, `POST …/test` | -| Git | `GET /api/projects/{project}/git-integration`, `POST …/git-integration/repositories`, `POST …/repositories/{id}/secret`, `DELETE …/repositories/{id}`, `POST /api/webhooks/git/{project}` (lo llama el host de Git) | -| Explorar | `GET /api/explore/projects/{project}/showcase` (sin token, proyectos públicos): hoja de ruta con votos, versiones, sprint en curso, tickets `good-first-issue` y propuestas abiertas; `POST …/proposals` (sin token) | -| Sistema | `GET /api/health`, `GET /api/auth/config`, `POST /api/auth/token`, `POST /api/webhooks/pireon` | +| Recurso | Rutas | +| ----------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Usuarios | `GET /api/users/me`, `GET /api/users?search=`, `GET /api/users/{id}` | +| Proyectos | `GET/POST /api/projects`, `GET/PATCH/DELETE /api/projects/{project}` (`DELETE …?confirm=CLAVE`), `POST …/archive`, `POST …/unarchive`, `PUT …/settings`, `…/board`, `…/epics`, `…/stats`, `…/analytics?days=`, `…/insights`, `…/activity`, `…/members` (POST, PATCH, DELETE) | +| Tickets | `GET /api/me/home` (portada), `GET /api/me/portfolio` (cartera), `GET /api/me/issues` (mi trabajo; filtros `project`, `priority`, `bugsOnly`, `overdue`), `GET/POST /api/projects/{project}/issues`, `GET/PATCH /api/issues/{issue}`, `PUT …/status`, `PUT …/assignee`, `PUT …/release`, `PUT/DELETE …/watch`, `GET /api/me/pins` y `PUT/DELETE …/pin` (fijados), `PUT/DELETE …/presence`, `PUT …/parent`, `PUT …/rank`, `POST …/checklist`, `PATCH/DELETE …/checklist/{item}`, `POST/DELETE …/links`, `GET/POST …/comments`, `GET …/history`, `POST /api/issues/{issue}/duplicate`, `PUT/DELETE …/vote` | +| Comentarios | `PATCH/DELETE /api/comments/{commentId}`, `POST …/reactions` | +| Sprints | `GET/POST /api/projects/{project}/sprints`, `GET/PATCH/DELETE /api/sprints/{sprint}`, `POST …/start`, `POST …/close`, `GET …/progress`, `…/burndown`, `…/issues`, `PUT /api/issues/{issue}/sprint` | +| Vistas | `GET/POST /api/projects/{project}/filters`, `DELETE /api/filters/{id}` | +| Avisos | `GET /api/me/notifications`, `POST /api/me/notifications/read`, `GET/PUT /api/me/notification-settings` | +| Adjuntos | `POST /api/issues/{issue}/attachments` (multipart: `file` y opcional `commentId`), `GET/DELETE /api/issues/{issue}/attachments/{id}` | +| Releases | `GET /api/me/releases` (próximas), `GET/POST /api/projects/{project}/releases`, `GET/PATCH /api/releases/{id}`, `PUT …/notes`, `GET …/notes/draft`, `…/progress`, `…/burnup`, `…/issues`, `POST …/publish`, `POST …/archive` | +| Explorar | `GET /api/explore/projects` (proyectos públicos, sin token) | +| Compartir | `GET/PUT/DELETE /api/projects/{project}/share` (enlace para clientes), `GET /api/shared/dashboards/{token}` (lo que enseña, sin token) | +| Importar | `GET /api/projects/{project}/export.csv`, `POST /api/projects/{project}/import?dryRun=` (multipart `file`) | +| Webhooks | `GET /api/webhook-events`, `GET/POST /api/projects/{project}/webhooks`, `PATCH/DELETE /api/outgoing-webhooks/{id}`, `POST …/test` | +| Git | `GET /api/projects/{project}/git-integration`, `POST …/git-integration/repositories`, `POST …/repositories/{id}/secret`, `DELETE …/repositories/{id}`, `POST /api/webhooks/git/{project}` (lo llama el host de Git) | +| Explorar | `GET /api/explore/projects/{project}/showcase` (sin token, proyectos públicos): hoja de ruta con votos, versiones, sprint en curso, tickets `good-first-issue` y propuestas abiertas; `POST …/proposals` (sin token) | +| Sistema | `GET /api/health`, `GET /api/auth/config`, `POST /api/auth/token`, `POST /api/webhooks/pireon` | 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 d463e28..cabcae1 100644 --- a/docs/04-cliente-web.md +++ b/docs/04-cliente-web.md @@ -301,11 +301,11 @@ visualización de la plataforma: | -------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `/login` | Inicio de sesión (solo sin sesión) | | `/auth/callback` | Retorno de Pireon (PKCE) | -| `/` | Inicio: saludo según la hora con el resumen del día, «Ir a…» (`WEB-12`), primeros pasos, mis proyectos con su actividad (lo primero, para llegar antes), cuatro cifras (`ts-home-kpis`: abiertos míos, esperando QA, terminados por mí y por el equipo esta semana), la semana (`ts-week-agenda`), mis vistas guardadas y dos columnas: mis tickets y lo último en mis proyectos; sprints en curso, esperando QA, que sigo, próximas releases, quién está ahora y «Retomar» | +| `/` | Inicio: saludo según la hora con el resumen del día, «Ir a…» (`WEB-12`), primeros pasos, mis proyectos con su actividad (lo primero, para llegar antes), mis fijados (`ts-pinned`, una franja que se quita con ×; solo si hay), cuatro cifras (`ts-home-kpis`: abiertos míos, esperando QA, terminados por mí y por el equipo esta semana), la semana (`ts-week-agenda`), mis vistas guardadas y dos columnas: mis tickets y lo último en mis proyectos; sprints en curso, esperando QA, que sigo, próximas releases, quién está ahora y «Retomar» | | `/portfolio` | Cartera (con sesión): una tarjeta por proyecto activo con lo abierto y en riesgo, el sprint en curso (¿llega?), las próximas releases con su previsión y las épicas abiertas; en directo: se vuelve a leer sola cuando cambia un ticket, sprint o release de esos proyectos | | `/explore` | Proyectos públicos (sin sesión) | | `/p/:key/board` | Tablero kanban (`?release=` y `?assignee=` preseleccionan los filtros). Quien puede contribuir cambia prioridad, estimación y persona desde la propia tarjeta (selectores nativos sobre sus distintivos; el título es el enlace) | -| `/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/issues/:issueKey` | Ticket. Encima del título, quién más lo está viendo o editando ahora (`PresenceFacade`: late cada 10 s; editar es tener abierto el formulario del ticket o de un comentario, o estar escribiendo uno). «Fijar» lo deja a mano en el inicio y en la paleta de búsqueda | | `/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/libs/api/tracker/application/src/index.ts b/libs/api/tracker/application/src/index.ts index fbb5855..7505cbd 100644 --- a/libs/api/tracker/application/src/index.ts +++ b/libs/api/tracker/application/src/index.ts @@ -33,6 +33,9 @@ export * from './lib/git/git-payloads'; export * from './lib/saved-filters/saved-filters'; export * from './lib/saved-filters/my-views'; export * from './lib/ports/my-views.repository'; +export * from './lib/saved-filters/pins'; +export * from './lib/ports/pins.repository'; +export * from './lib/pins.service'; export * from './lib/tracker-application.module'; export * from './lib/ports/presence.store'; export * from './lib/presence.service'; diff --git a/libs/api/tracker/application/src/lib/pins.service.ts b/libs/api/tracker/application/src/lib/pins.service.ts new file mode 100644 index 0000000..8e8b248 --- /dev/null +++ b/libs/api/tracker/application/src/lib/pins.service.ts @@ -0,0 +1,31 @@ +import { CommandRunner } from '@task-stream/api/shared/application'; +import { Injectable } from '@nestjs/common'; +import { QueryBus } from '@nestjs/cqrs'; +import { IssueReadModel } from '@task-stream/api/tracker/domain'; +import { + GetMyPinsQuery, + IsIssuePinnedQuery, + PinIssueCommand, +} from './saved-filters/pins'; + +/** Pinned tickets: the few someone keeps at hand, across projects. */ +@Injectable() +export class PinsService { + constructor( + private readonly commands: CommandRunner, + private readonly queryBus: QueryBus, + ) {} + + /** Mine, most recently pinned first. */ + mine(ownerId: string): Promise { + return this.queryBus.execute(new GetMyPinsQuery(ownerId)); + } + + isPinned(ownerId: string, issueId: string): Promise { + return this.queryBus.execute(new IsIssuePinnedQuery(ownerId, issueId)); + } + + pin(ownerId: string, issueId: string, pinned: boolean): Promise { + return this.commands.execute(new PinIssueCommand(ownerId, issueId, pinned)); + } +} diff --git a/libs/api/tracker/application/src/lib/ports/pins.repository.ts b/libs/api/tracker/application/src/lib/ports/pins.repository.ts new file mode 100644 index 0000000..eac21e3 --- /dev/null +++ b/libs/api/tracker/application/src/lib/ports/pins.repository.ts @@ -0,0 +1,12 @@ +import { PinnedIssueReadModel } from '@task-stream/api/tracker/domain'; + +/** Pinned tickets: one person's shortcuts, no history. */ +export abstract class PinsRepository { + /** Most recently pinned first. */ + abstract findByOwner(ownerId: string): Promise; + abstract isPinned(ownerId: string, issueId: string): Promise; + abstract countByOwner(ownerId: string): Promise; + /** Pinning again keeps the first date. */ + abstract pin(pin: PinnedIssueReadModel): Promise; + abstract unpin(ownerId: string, issueId: string): Promise; +} diff --git a/libs/api/tracker/application/src/lib/saved-filters/pins.spec.ts b/libs/api/tracker/application/src/lib/saved-filters/pins.spec.ts new file mode 100644 index 0000000..353d334 --- /dev/null +++ b/libs/api/tracker/application/src/lib/saved-filters/pins.spec.ts @@ -0,0 +1,142 @@ +import { + CommandBus, + CqrsModule, + IQueryHandler, + QueryBus, + QueryHandler, +} from '@nestjs/cqrs'; +import { Test } from '@nestjs/testing'; +import { + BusinessRuleError, + NotFoundError, +} from '@task-stream/api/shared/domain'; +import { + IssueReadModel, + PinnedIssueReadModel, +} from '@task-stream/api/tracker/domain'; +import { PinsRepository } from '../ports/pins.repository'; +import { GetIssuesByIdsQuery } from '../queries/get-issues-by-ids.query'; +import { + GetMyPinsQuery, + IsIssuePinnedQuery, + MAX_PINS, + PINS, + PinIssueCommand, +} from './pins'; + +class InMemoryPins extends PinsRepository { + rows: PinnedIssueReadModel[] = []; + async findByOwner(ownerId: string) { + return this.rows + .filter((p) => p.ownerId === ownerId) + .sort((a, b) => b.pinnedAt.localeCompare(a.pinnedAt)); + } + async isPinned(ownerId: string, issueId: string) { + return this.rows.some( + (p) => p.ownerId === ownerId && p.issueId === issueId, + ); + } + async countByOwner(ownerId: string) { + return this.rows.filter((p) => p.ownerId === ownerId).length; + } + async pin(pin: PinnedIssueReadModel) { + if (!(await this.isPinned(pin.ownerId, pin.issueId))) this.rows.push(pin); + } + async unpin(ownerId: string, issueId: string) { + this.rows = this.rows.filter( + (p) => !(p.ownerId === ownerId && p.issueId === issueId), + ); + } +} + +/** What each person can read: `ana` all but `hidden`, in the trash `old`. */ +const issues = new Map(); +const readable = (viewerId: string | null, id: string) => + viewerId === 'ana' && id !== 'hidden'; + +@QueryHandler(GetIssuesByIdsQuery) +class FakeIssuesByIds implements IQueryHandler { + async execute(query: GetIssuesByIdsQuery) { + return query.ids + .filter((id) => readable(query.viewerId, id)) + .map( + (id) => + issues.get(id) ?? + ({ + id, + trashedAt: id === 'old' ? '2026-01-01' : null, + } as IssueReadModel), + ); + } +} + +async function setup() { + const rows = new InMemoryPins(); + const moduleRef = await Test.createTestingModule({ + imports: [CqrsModule.forRoot()], + providers: [ + ...PINS, + FakeIssuesByIds, + { provide: PinsRepository, useValue: rows }, + ], + }).compile(); + await moduleRef.init(); + return { + rows, + commands: moduleRef.get(CommandBus), + queries: moduleRef.get(QueryBus), + }; +} + +describe('pinned tickets', () => { + it('pins, lists most recent first and unpins', async () => { + const { commands, queries } = await setup(); + await commands.execute(new PinIssueCommand('ana', 'a', true)); + await new Promise((r) => setTimeout(r, 2)); + await commands.execute(new PinIssueCommand('ana', 'b', true)); + // Twice is still once. + await commands.execute(new PinIssueCommand('ana', 'b', true)); + const mine = await queries.execute(new GetMyPinsQuery('ana')); + expect(mine.map((i: IssueReadModel) => i.id)).toEqual(['b', 'a']); + expect(await queries.execute(new IsIssuePinnedQuery('ana', 'a'))).toBe( + true, + ); + await commands.execute(new PinIssueCommand('ana', 'a', false)); + expect(await queries.execute(new IsIssuePinnedQuery('ana', 'a'))).toBe( + false, + ); + }); + + it('does not pin what I cannot read, nor what is in the trash', async () => { + const { commands } = await setup(); + await expect( + commands.execute(new PinIssueCommand('ana', 'hidden', true)), + ).rejects.toBeInstanceOf(NotFoundError); + await expect( + commands.execute(new PinIssueCommand('ana', 'old', true)), + ).rejects.toBeInstanceOf(NotFoundError); + }); + + it('keeps a pin to a trashed ticket but does not list it', async () => { + const { commands, queries, rows } = await setup(); + await commands.execute(new PinIssueCommand('ana', 'a', true)); + await rows.pin({ ownerId: 'ana', issueId: 'old', pinnedAt: '2026-01-02' }); + const mine = await queries.execute(new GetMyPinsQuery('ana')); + expect(mine.map((i: IssueReadModel) => i.id)).toEqual(['a']); + expect(rows.rows).toHaveLength(2); + }); + + it(`stops at ${MAX_PINS}, after forgetting the ones out of reach`, async () => { + const { commands, rows } = await setup(); + for (let i = 0; i < MAX_PINS - 1; i++) + await rows.pin({ ownerId: 'ana', issueId: `t${i}`, pinnedAt: `${i}` }); + await rows.pin({ ownerId: 'ana', issueId: 'hidden', pinnedAt: 'x' }); + // Full, but one is out of reach: it makes room. + await commands.execute(new PinIssueCommand('ana', 'new', true)); + expect(await rows.isPinned('ana', 'hidden')).toBe(false); + expect(await rows.countByOwner('ana')).toBe(MAX_PINS); + await expect( + commands.execute(new PinIssueCommand('ana', 'one-more', true)), + ).rejects.toBeInstanceOf(BusinessRuleError); + }); +}); diff --git a/libs/api/tracker/application/src/lib/saved-filters/pins.ts b/libs/api/tracker/application/src/lib/saved-filters/pins.ts new file mode 100644 index 0000000..ee9aa63 --- /dev/null +++ b/libs/api/tracker/application/src/lib/saved-filters/pins.ts @@ -0,0 +1,150 @@ +import { + Command, + CommandHandler, + ICommandHandler, + IQueryHandler, + Query, + QueryBus, + QueryHandler, +} from '@nestjs/cqrs'; +import { + BusinessRuleError, + NotFoundError, +} from '@task-stream/api/shared/domain'; +import { IssueReadModel } from '@task-stream/api/tracker/domain'; +import { PinsRepository } from '../ports/pins.repository'; +import { GetIssuesByIdsQuery } from '../queries/get-issues-by-ids.query'; + +/* + * Pinned tickets: the few someone keeps at hand, on their home page and in + * the search palette. Private, across projects, no history (like the views + * of «my work»). + */ + +/** A person keeps at most this many. */ +export const MAX_PINS = 20; + +export class PinIssueCommand extends Command { + constructor( + public readonly ownerId: string, + public readonly issueId: string, + /** False: unpin. */ + public readonly pinned: boolean, + ) { + super(); + } +} + +/** My pinned tickets that I can still read and are not in the trash. */ +export class GetMyPinsQuery extends Query { + constructor(public readonly ownerId: string) { + super(); + } +} + +export class IsIssuePinnedQuery extends Query { + constructor( + public readonly ownerId: string, + public readonly issueId: string, + ) { + super(); + } +} + +@CommandHandler(PinIssueCommand) +export class PinIssueCommandHandler + implements ICommandHandler +{ + constructor( + private readonly pins: PinsRepository, + private readonly queryBus: QueryBus, + ) {} + + async execute(command: PinIssueCommand): Promise { + const { ownerId, issueId } = command; + if (!command.pinned) return this.pins.unpin(ownerId, issueId); + const [issue] = await this.queryBus.execute( + new GetIssuesByIdsQuery(ownerId, [issueId]), + ); + // A ticket I cannot read does not exist, as far as I know. + if (!issue || issue.trashedAt) throw new NotFoundError('Issue', issueId); + if (await this.pins.isPinned(ownerId, issueId)) return; + if ( + (await this.pins.countByOwner(ownerId)) >= MAX_PINS && + (await this.dropUnreadable(ownerId)) >= MAX_PINS + ) + throw new BusinessRuleError( + `You already keep ${MAX_PINS} pinned tickets; unpin one first`, + ); + await this.pins.pin({ + ownerId, + issueId, + pinnedAt: new Date().toISOString(), + }); + } + + /** + * Forgets the pins of tickets I can no longer read (I left the project, + * it was deleted) so they do not hold a place; returns how many remain. + */ + private async dropUnreadable(ownerId: string): Promise { + const pins = await this.pins.findByOwner(ownerId); + const readable = new Set( + ( + await this.queryBus.execute( + new GetIssuesByIdsQuery( + ownerId, + pins.map((p) => p.issueId), + ), + ) + ).map((issue) => issue.id), + ); + for (const pin of pins) + if (!readable.has(pin.issueId)) + await this.pins.unpin(ownerId, pin.issueId); + return readable.size; + } +} + +@QueryHandler(GetMyPinsQuery) +export class GetMyPinsQueryHandler + implements IQueryHandler +{ + constructor( + private readonly pins: PinsRepository, + private readonly queryBus: QueryBus, + ) {} + + async execute(query: GetMyPinsQuery): Promise { + const pins = await this.pins.findByOwner(query.ownerId); + if (pins.length === 0) return []; + const issues: IssueReadModel[] = await this.queryBus.execute( + new GetIssuesByIdsQuery( + query.ownerId, + pins.map((p) => p.issueId), + ), + ); + // In the trash or out of reach: kept, in case it comes back. + const byId = new Map( + issues.filter((i) => !i.trashedAt).map((i) => [i.id, i]), + ); + return pins.flatMap((p) => byId.get(p.issueId) ?? []); + } +} + +@QueryHandler(IsIssuePinnedQuery) +export class IsIssuePinnedQueryHandler + implements IQueryHandler +{ + constructor(private readonly pins: PinsRepository) {} + + execute(query: IsIssuePinnedQuery): Promise { + return this.pins.isPinned(query.ownerId, query.issueId); + } +} + +export const PINS = [ + PinIssueCommandHandler, + GetMyPinsQueryHandler, + IsIssuePinnedQueryHandler, +]; 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 740208e..a5c3ae3 100644 --- a/libs/api/tracker/application/src/lib/tracker-application.module.ts +++ b/libs/api/tracker/application/src/lib/tracker-application.module.ts @@ -35,6 +35,8 @@ import { import { SAVED_FILTERS } from './saved-filters/saved-filters'; import { MY_VIEWS } from './saved-filters/my-views'; +import { PINS } from './saved-filters/pins'; +import { PinsService } from './pins.service'; import { SendDueRemindersCommandHandler } from './notifications/reminders'; import { SavedFiltersService } from './saved-filters.service'; import { SprintsService } from './sprints.service'; @@ -78,6 +80,7 @@ const SERVICES = [ BlockersService, NotificationsService, SavedFiltersService, + PinsService, SprintsService, GitService, WebhooksService, @@ -108,6 +111,7 @@ const SERVICES = [ ...NOTIFICATIONS, ...SAVED_FILTERS, ...MY_VIEWS, + ...PINS, ...GIT_HANDLERS, ...WEBHOOK_HANDLERS, ...TRANSFER_HANDLERS, diff --git a/libs/api/tracker/domain/src/lib/read-models/saved-filter.read-model.ts b/libs/api/tracker/domain/src/lib/read-models/saved-filter.read-model.ts index 8a9468a..3ff18e1 100644 --- a/libs/api/tracker/domain/src/lib/read-models/saved-filter.read-model.ts +++ b/libs/api/tracker/domain/src/lib/read-models/saved-filter.read-model.ts @@ -56,3 +56,10 @@ export class MyViewReadModel { criteria!: MyViewCriteriaReadModel; createdAt!: string; } + +/** A ticket someone keeps at hand: private to them, no history. */ +export class PinnedIssueReadModel { + ownerId!: string; + issueId!: string; + pinnedAt!: string; +} diff --git a/libs/api/tracker/infrastructure/src/lib/mongo/pins.repository.ts b/libs/api/tracker/infrastructure/src/lib/mongo/pins.repository.ts new file mode 100644 index 0000000..689c4a8 --- /dev/null +++ b/libs/api/tracker/infrastructure/src/lib/mongo/pins.repository.ts @@ -0,0 +1,48 @@ +import { Injectable } from '@nestjs/common'; +import { InjectModel } from '@nestjs/mongoose'; +import { PinsRepository } from '@task-stream/api/tracker/application'; +import { PinnedIssueReadModel } from '@task-stream/api/tracker/domain'; +import { Model } from 'mongoose'; +import { PinnedIssueDoc, ReadModels } from './read-model.schemas'; + +@Injectable() +export class MongoPinsRepository extends PinsRepository { + constructor( + @InjectModel(ReadModels.PinnedIssue) + private readonly pins: Model, + ) { + super(); + } + + async findByOwner(ownerId: string): Promise { + const docs = await this.pins + .find({ ownerId }) + .sort({ pinnedAt: -1 }) + .lean(); + return docs.map(({ ownerId, issueId, pinnedAt }) => ({ + ownerId, + issueId, + pinnedAt, + })); + } + + async isPinned(ownerId: string, issueId: string): Promise { + return !!(await this.pins.exists({ ownerId, issueId })); + } + + countByOwner(ownerId: string): Promise { + return this.pins.countDocuments({ ownerId }); + } + + async pin(pin: PinnedIssueReadModel): Promise { + await this.pins.updateOne( + { ownerId: pin.ownerId, issueId: pin.issueId }, + { $setOnInsert: pin }, + { upsert: true }, + ); + } + + async unpin(ownerId: string, issueId: string): Promise { + await this.pins.deleteOne({ ownerId, issueId }); + } +} diff --git a/libs/api/tracker/infrastructure/src/lib/mongo/read-model.schemas.ts b/libs/api/tracker/infrastructure/src/lib/mongo/read-model.schemas.ts index 8a3718b..e0047db 100644 --- a/libs/api/tracker/infrastructure/src/lib/mongo/read-model.schemas.ts +++ b/libs/api/tracker/infrastructure/src/lib/mongo/read-model.schemas.ts @@ -13,6 +13,7 @@ import { ReleaseReadModel, SavedFilterReadModel, MyViewReadModel, + PinnedIssueReadModel, SprintReadModel, implicitRank, } from '@task-stream/api/tracker/domain'; @@ -42,6 +43,7 @@ export type TimelineDoc = Doc; export type NotificationDoc = Doc; export type SavedFilterDoc = Doc; export type MyViewDoc = Doc; +export type PinnedIssueDoc = PinnedIssueReadModel & { _id?: Types.ObjectId }; export type OutgoingWebhookDoc = Doc; export type AutomationDoc = Doc; export interface AutomationRunDoc { @@ -104,6 +106,7 @@ export const ReadModels = { NotificationSettings: 'NotificationSettings', SavedFilter: 'SavedFilter', MyView: 'MyView', + PinnedIssue: 'PinnedIssue', Sprint: 'SprintReadModel', GitIntegration: 'GitIntegration', OutgoingWebhook: 'OutgoingWebhook', @@ -322,6 +325,16 @@ class MyViewDocument { } export const MyViewSchema = SchemaFactory.createForClass(MyViewDocument); +@Schema({ ...LOOSE, collection: 'pinned_issues' }) +class PinnedIssueDocument { + @Prop({ type: String }) ownerId!: string; + @Prop({ type: String }) issueId!: string; + @Prop({ type: String }) pinnedAt!: string; +} +export const PinnedIssueSchema = + SchemaFactory.createForClass(PinnedIssueDocument); +PinnedIssueSchema.index({ ownerId: 1, issueId: 1 }, { unique: true }); + @Schema({ ...LOOSE, collection: 'git_integrations' }) class GitIntegrationDocument { /** The project's id. */ @@ -424,6 +437,7 @@ export const READ_MODEL_SCHEMAS = [ { name: ReadModels.NotificationSettings, schema: NotificationSettingsSchema }, { name: ReadModels.SavedFilter, schema: SavedFilterSchema }, { name: ReadModels.MyView, schema: MyViewSchema }, + { name: ReadModels.PinnedIssue, schema: PinnedIssueSchema }, { name: ReadModels.CalendarFeed, schema: CalendarFeedSchema }, { name: ReadModels.DashboardShare, schema: DashboardShareSchema }, { name: ReadModels.PushSubscription, schema: PushSubscriptionSchema }, 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 5c6f826..4362a83 100644 --- a/libs/api/tracker/infrastructure/src/lib/tracker-infrastructure.module.ts +++ b/libs/api/tracker/infrastructure/src/lib/tracker-infrastructure.module.ts @@ -13,6 +13,7 @@ import { PushSender, SavedFiltersRepository, MyViewsRepository, + PinsRepository, GitIntegrationsRepository, OutgoingWebhooksRepository, AutomationsRepository, @@ -78,6 +79,7 @@ import { MongoPushSubscriptionsRepository } from './mongo/push-subscriptions.rep import { WebPushSender } from './push/web-push.sender'; import { MongoSavedFiltersRepository } from './mongo/saved-filters.repository'; import { MongoMyViewsRepository } from './mongo/my-views.repository'; +import { MongoPinsRepository } from './mongo/pins.repository'; import { MongoGitIntegrationsRepository } from './mongo/git-integrations.repository'; import { MongoOutgoingWebhooksRepository } from './mongo/outgoing-webhooks.repository'; import { HttpWebhookSender } from './webhooks/http-webhook.sender'; @@ -154,6 +156,7 @@ const PORTS = [ { provide: PushSender, useClass: WebPushSender }, { provide: SavedFiltersRepository, useClass: MongoSavedFiltersRepository }, { provide: MyViewsRepository, useClass: MongoMyViewsRepository }, + { provide: PinsRepository, useClass: MongoPinsRepository }, { provide: GitIntegrationsRepository, useClass: MongoGitIntegrationsRepository, 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 9d2eadb..329a13e 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, + PinsService, PresenceService, WorkflowsService, } from '@task-stream/api/tracker/application'; @@ -60,6 +61,7 @@ export class IssueResolver { private readonly workflows: WorkflowsService, private readonly blockers: BlockersService, private readonly presence: PresenceService, + private readonly pins: PinsService, ) {} @Mutation(() => [PresenceGql], { @@ -245,6 +247,16 @@ export class IssueResolver { return !!me && issue.voterIds.includes(me.id); } + @ResolveField(() => Boolean, { + description: 'Whether I pinned it (false without a token)', + }) + pinned( + @CurrentUser() me: User | undefined, + @Parent() issue: IssueReadModel, + ): Promise | boolean { + return !!me && this.pins.isPinned(me.id, issue.id); + } + @ResolveField(() => [IssueGql], { description: 'Linked tickets I can see' }) linkedIssues( @CurrentUser() me: User | undefined, @@ -317,6 +329,35 @@ export class IssueResolver { return this.issuesService.watch(me.id, issueId, true); } + @Query(() => [IssueGql], { + description: + 'My pinned tickets, most recently pinned first (trashed or out of reach left out)', + }) + myPins(@CurrentUser() me: User) { + return this.pins.mine(me.id); + } + + @Mutation(() => Boolean, { + description: + 'Pin a ticket: keep it at hand on my home page and in search (private, at most 20)', + }) + async pinIssue( + @CurrentUser() me: User, + @Args('issueId', { type: () => ID }) issueId: string, + ): Promise { + await this.pins.pin(me.id, issueId, true); + return true; + } + + @Mutation(() => Boolean, { description: 'Unpin a ticket' }) + async unpinIssue( + @CurrentUser() me: User, + @Args('issueId', { type: () => ID }) issueId: string, + ): Promise { + await this.pins.pin(me.id, issueId, false); + return true; + } + @Mutation(() => CommandResultGql, { description: 'Stop following a ticket' }) unwatchIssue( @CurrentUser() me: User, 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 bce1d4a..2b99552 100644 --- a/libs/api/tracker/presenters/src/lib/http/issues.controller.ts +++ b/libs/api/tracker/presenters/src/lib/http/issues.controller.ts @@ -32,6 +32,7 @@ import { import { CommentsService, IssuesService, + PinsService, PresenceService, ProjectsService, } from '@task-stream/api/tracker/application'; @@ -94,6 +95,7 @@ export class IssuesController { private readonly refs: Refs, private readonly presence: PresenceService, private readonly projects: ProjectsService, + private readonly pins: PinsService, ) {} @Get('me/home') @@ -163,6 +165,41 @@ export class IssuesController { await this.presence.leave(me.id, await this.refs.issueId(me.id, issue)); } + @Get('me/pins') + @ApiOperation({ + summary: 'My pinned tickets, most recently pinned first', + description: + 'Across my projects; tickets in the trash or out of my reach are left out.', + }) + @ApiOkResponse({ type: [IssueDto] }) + @ApiReadErrors() + async myPins(@CurrentUser() me: User) { + return this.refs.issueDtos(await this.pins.mine(me.id), viewerOf(me)); + } + + @Put('issues/:issue/pin') + @HttpCode(204) + @ISSUE + @ApiOperation({ + summary: 'Pin a ticket: keep it at hand on my home page and in search', + description: 'Private to me; at most 20. Pinning it again changes nothing.', + }) + @ApiNoContentResponse() + @ApiCommandErrors() + async pin(@CurrentUser() me: User, @Param('issue') issue: string) { + await this.pins.pin(me.id, await this.refs.issueId(me.id, issue), true); + } + + @Delete('issues/:issue/pin') + @HttpCode(204) + @ISSUE + @ApiOperation({ summary: 'Unpin a ticket' }) + @ApiNoContentResponse() + @ApiCommandErrors() + async unpin(@CurrentUser() me: User, @Param('issue') issue: string) { + await this.pins.pin(me.id, await this.refs.issueId(me.id, issue), false); + } + @Put('issues/:issue/watch') @ISSUE @ApiOperation({ diff --git a/libs/web/shared/ui/src/lib/icons.ts b/libs/web/shared/ui/src/lib/icons.ts index 1203335..36aa27f 100644 --- a/libs/web/shared/ui/src/lib/icons.ts +++ b/libs/web/shared/ui/src/lib/icons.ts @@ -112,6 +112,7 @@ export const APP_ICONS: Record = { task: '', palette: '', + pin: '', eye: '', 'eye-off': '', diff --git a/libs/web/tracker/application/src/lib/events/issue.events.ts b/libs/web/tracker/application/src/lib/events/issue.events.ts index a77134d..e6b67be 100644 --- a/libs/web/tracker/application/src/lib/events/issue.events.ts +++ b/libs/web/tracker/application/src/lib/events/issue.events.ts @@ -43,6 +43,7 @@ export const issuePageEvents = eventGroup({ replyTo?: string | null; }>(), watchRequested: type<{ issueId: string; watching: boolean }>(), + pinRequested: type<{ issueId: string; pinned: boolean }>(), trashRequested: type<{ issueId: string; trashed: boolean }>(), /** Place the ticket under another one by its key (e.g. WEB-3); null: out. */ parentRequested: type<{ issueId: string; parentKey: string | null }>(), diff --git a/libs/web/tracker/application/src/lib/facades/issue.facade.ts b/libs/web/tracker/application/src/lib/facades/issue.facade.ts index 3fcffd4..b22a2b6 100644 --- a/libs/web/tracker/application/src/lib/facades/issue.facade.ts +++ b/libs/web/tracker/application/src/lib/facades/issue.facade.ts @@ -120,6 +120,13 @@ export class IssueFacade { ); } + /** Pin the ticket for me (or unpin it): at hand on my home page and in search. */ + setPinned(pinned: boolean): void { + this.withIssue((issueId) => + this.dispatch.pinRequested({ issueId, pinned }), + ); + } + /** Places the open ticket under another one by its key; null: back to the top. */ /** * Tickets to link to or hang the open one from: the most recently updated 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 59c7699..2bab5de 100644 --- a/libs/web/tracker/application/src/lib/stores/issue.store.ts +++ b/libs/web/tracker/application/src/lib/stores/issue.store.ts @@ -337,6 +337,20 @@ export const IssueStore = signalStore( ), ), ), + events + .on(issuePageEvents.pinRequested) + .pipe( + mergeMap(({ payload }) => + runCommand( + issues.pin(payload.issueId, payload.pinned), + payload.pinned + ? 'Fijado: lo tienes a mano en el inicio y al buscar' + : 'Ya no está fijado', + done, + failed, + ), + ), + ), events .on(issuePageEvents.watchRequested) .pipe( diff --git a/libs/web/tracker/domain/src/lib/models/models.ts b/libs/web/tracker/domain/src/lib/models/models.ts index 8c7e163..fa099fb 100644 --- a/libs/web/tracker/domain/src/lib/models/models.ts +++ b/libs/web/tracker/domain/src/lib/models/models.ts @@ -190,6 +190,8 @@ export interface Issue extends IssueSummary { attachments: Attachment[]; /** People following the ticket. */ watchers: UserRef[]; + /** Whether I keep it pinned (home page and search). */ + pinned: boolean; checklist: ChecklistItem[]; /** Commits, branches and pull requests that mention it. */ devLinks: DevLink[]; diff --git a/libs/web/tracker/domain/src/lib/ports/gateways.ts b/libs/web/tracker/domain/src/lib/ports/gateways.ts index f0c8dc9..1612591 100644 --- a/libs/web/tracker/domain/src/lib/ports/gateways.ts +++ b/libs/web/tracker/domain/src/lib/ports/gateways.ts @@ -140,6 +140,10 @@ export abstract class IssuesGateway { ): Observable; /** The signed-in person follows (or stops following) the ticket. */ abstract watch(issueId: string, watching: boolean): Observable; + /** My pinned tickets, most recently pinned first. */ + abstract pins(): Observable; + /** Pins the ticket for me (or unpins it): private, at most 20. */ + abstract pin(issueId: string, pinned: boolean): Observable; /** Tickets hanging from `issueId`. */ abstract children( projectId: string, diff --git a/libs/web/tracker/infrastructure/src/lib/adapters/graphql-issues.gateway.ts b/libs/web/tracker/infrastructure/src/lib/adapters/graphql-issues.gateway.ts index c537894..d655381 100644 --- a/libs/web/tracker/infrastructure/src/lib/adapters/graphql-issues.gateway.ts +++ b/libs/web/tracker/infrastructure/src/lib/adapters/graphql-issues.gateway.ts @@ -43,9 +43,12 @@ import { SetIssueParentDocument, SetIssueReleaseDocument, UnlinkIssuesDocument, + UnpinIssueDocument, UnwatchIssueDocument, VoteIssueDocument, WatchIssueDocument, + MyPinsDocument, + PinIssueDocument, UpdateChecklistItemDocument, UpdateIssueDocument, } from '../graphql/generated'; @@ -192,6 +195,19 @@ export class GraphqlIssuesGateway extends IssuesGateway { .pipe(map((d) => toCommandResult(d.voteIssue))); } + pins(): Observable { + return this.gql + .query(MyPinsDocument, {}) + .pipe(map((d) => d.myPins.map(toIssueSummary))); + } + + pin(issueId: string, pinned: boolean): Observable { + const done$: Observable = pinned + ? this.gql.mutate(PinIssueDocument, { issueId }) + : this.gql.mutate(UnpinIssueDocument, { issueId }); + return done$.pipe(map(() => undefined)); + } + watch(issueId: string, watching: boolean): Observable { return watching ? this.gql diff --git a/libs/web/tracker/infrastructure/src/lib/graphql/fragments.graphql b/libs/web/tracker/infrastructure/src/lib/graphql/fragments.graphql index a711e0f..028d8aa 100644 --- a/libs/web/tracker/infrastructure/src/lib/graphql/fragments.graphql +++ b/libs/web/tracker/infrastructure/src/lib/graphql/fragments.graphql @@ -120,6 +120,7 @@ fragment IssueSummaryFields on Issue { fragment IssueFields on Issue { ...IssueSummaryFields + pinned description figmaUrl createdAt diff --git a/libs/web/tracker/infrastructure/src/lib/graphql/generated.ts b/libs/web/tracker/infrastructure/src/lib/graphql/generated.ts index c83ba67..7c8370c 100644 --- a/libs/web/tracker/infrastructure/src/lib/graphql/generated.ts +++ b/libs/web/tracker/infrastructure/src/lib/graphql/generated.ts @@ -624,6 +624,7 @@ export type IssueSummaryFieldsFragment = { }; export type IssueFieldsFragment = { + pinned: boolean; description: string | null; figmaUrl: string | null; createdAt: string; @@ -846,6 +847,7 @@ export type IssueQueryVariables = Exact<{ export type IssueQuery = { issue: { + pinned: boolean; description: string | null; figmaUrl: string | null; createdAt: string; @@ -1012,6 +1014,52 @@ export type UnwatchIssueMutation = { unwatchIssue: { id: string; key: string | null; version: number }; }; +export type MyPinsQueryVariables = Exact<{ [key: string]: never }>; + +export type MyPinsQuery = { + myPins: Array<{ + id: string; + projectId: string; + key: string; + type: string; + area: string | null; + team: string | null; + title: string; + status: string; + statusCategory: StatusCategory; + priority: IssuePriority; + labels: Array; + commentCount: number; + updatedAt: string; + version: number; + votes: number; + voted: boolean; + rank: string; + storyPoints: number | null; + startDate: string | null; + dueDate: string | null; + trashedAt: string | null; + assignee: { id: string; name: string } | null; + release: { id: string; name: string } | null; + sprint: { id: string; name: string } | null; + parent: { id: string; key: string; title: string } | null; + blockedBy: Array<{ id: string; key: string; title: string }>; + customFields: Array<{ fieldId: string; value: string }>; + }>; +}; + +export type PinIssueMutationVariables = Exact<{ + issueId: string; +}>; + +export type PinIssueMutation = { pinIssue: boolean }; + +export type UnpinIssueMutationVariables = Exact<{ + issueId: string; +}>; + +export type UnpinIssueMutation = { unpinIssue: boolean }; + export type SetIssueReleaseMutationVariables = Exact<{ issueId: string; releaseId?: string | null | undefined; @@ -3467,6 +3515,7 @@ export const IssueFieldsFragmentDoc = { kind: 'FragmentSpread', name: { kind: 'Name', value: 'IssueSummaryFields' }, }, + { kind: 'Field', name: { kind: 'Name', value: 'pinned' } }, { kind: 'Field', name: { kind: 'Name', value: 'description' } }, { kind: 'Field', name: { kind: 'Name', value: 'figmaUrl' } }, { kind: 'Field', name: { kind: 'Name', value: 'createdAt' } }, @@ -5681,6 +5730,7 @@ export const IssueDocument = { kind: 'FragmentSpread', name: { kind: 'Name', value: 'IssueSummaryFields' }, }, + { kind: 'Field', name: { kind: 'Name', value: 'pinned' } }, { kind: 'Field', name: { kind: 'Name', value: 'description' } }, { kind: 'Field', name: { kind: 'Name', value: 'figmaUrl' } }, { kind: 'Field', name: { kind: 'Name', value: 'createdAt' } }, @@ -6553,6 +6603,237 @@ export const UnwatchIssueDocument = { UnwatchIssueMutation, UnwatchIssueMutationVariables >; +export const MyPinsDocument = { + kind: 'Document', + definitions: [ + { + kind: 'OperationDefinition', + operation: 'query', + name: { kind: 'Name', value: 'MyPins' }, + selectionSet: { + kind: 'SelectionSet', + selections: [ + { + kind: 'Field', + name: { kind: 'Name', value: 'myPins' }, + selectionSet: { + kind: 'SelectionSet', + selections: [ + { + kind: 'FragmentSpread', + name: { kind: 'Name', value: 'IssueSummaryFields' }, + }, + ], + }, + }, + ], + }, + }, + { + 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' } }, + ], + }, + }, + { + kind: 'FragmentDefinition', + name: { kind: 'Name', value: 'IssueSummaryFields' }, + typeCondition: { + kind: 'NamedType', + name: { kind: 'Name', value: 'Issue' }, + }, + selectionSet: { + kind: 'SelectionSet', + selections: [ + { kind: 'Field', name: { kind: 'Name', value: 'id' } }, + { kind: 'Field', name: { kind: 'Name', value: 'projectId' } }, + { kind: 'Field', name: { kind: 'Name', value: 'key' } }, + { kind: 'Field', name: { kind: 'Name', value: 'type' } }, + { kind: 'Field', name: { kind: 'Name', value: 'area' } }, + { kind: 'Field', name: { kind: 'Name', value: 'team' } }, + { kind: 'Field', name: { kind: 'Name', value: 'title' } }, + { kind: 'Field', name: { kind: 'Name', value: 'status' } }, + { kind: 'Field', name: { kind: 'Name', value: 'statusCategory' } }, + { kind: 'Field', name: { kind: 'Name', value: 'priority' } }, + { kind: 'Field', name: { kind: 'Name', value: 'labels' } }, + { kind: 'Field', name: { kind: 'Name', value: 'commentCount' } }, + { kind: 'Field', name: { kind: 'Name', value: 'updatedAt' } }, + { kind: 'Field', name: { kind: 'Name', value: 'version' } }, + { + kind: 'Field', + name: { kind: 'Name', value: 'assignee' }, + selectionSet: { + kind: 'SelectionSet', + selections: [ + { + kind: 'FragmentSpread', + name: { kind: 'Name', value: 'UserRefFields' }, + }, + ], + }, + }, + { + kind: 'Field', + name: { kind: 'Name', value: 'release' }, + selectionSet: { + kind: 'SelectionSet', + selections: [ + { kind: 'Field', name: { kind: 'Name', value: 'id' } }, + { kind: 'Field', name: { kind: 'Name', value: 'name' } }, + ], + }, + }, + { + kind: 'Field', + name: { kind: 'Name', value: 'sprint' }, + selectionSet: { + kind: 'SelectionSet', + selections: [ + { kind: 'Field', name: { kind: 'Name', value: 'id' } }, + { kind: 'Field', name: { kind: 'Name', value: 'name' } }, + ], + }, + }, + { + kind: 'Field', + name: { kind: 'Name', value: 'parent' }, + selectionSet: { + kind: 'SelectionSet', + selections: [ + { kind: 'Field', name: { kind: 'Name', value: 'id' } }, + { kind: 'Field', name: { kind: 'Name', value: 'key' } }, + { kind: 'Field', name: { kind: 'Name', value: 'title' } }, + ], + }, + }, + { + kind: 'Field', + name: { kind: 'Name', value: 'blockedBy' }, + selectionSet: { + kind: 'SelectionSet', + selections: [ + { kind: 'Field', name: { kind: 'Name', value: 'id' } }, + { kind: 'Field', name: { kind: 'Name', value: 'key' } }, + { kind: 'Field', name: { kind: 'Name', value: 'title' } }, + ], + }, + }, + { kind: 'Field', name: { kind: 'Name', value: 'votes' } }, + { kind: 'Field', name: { kind: 'Name', value: 'voted' } }, + { kind: 'Field', name: { kind: 'Name', value: 'rank' } }, + { kind: 'Field', name: { kind: 'Name', value: 'storyPoints' } }, + { kind: 'Field', name: { kind: 'Name', value: 'startDate' } }, + { kind: 'Field', name: { kind: 'Name', value: 'dueDate' } }, + { + kind: 'Field', + name: { kind: 'Name', value: 'customFields' }, + selectionSet: { + kind: 'SelectionSet', + selections: [ + { kind: 'Field', name: { kind: 'Name', value: 'fieldId' } }, + { kind: 'Field', name: { kind: 'Name', value: 'value' } }, + ], + }, + }, + { kind: 'Field', name: { kind: 'Name', value: 'trashedAt' } }, + ], + }, + }, + ], +} as unknown as DocumentNode; +export const PinIssueDocument = { + kind: 'Document', + definitions: [ + { + kind: 'OperationDefinition', + operation: 'mutation', + name: { kind: 'Name', value: 'PinIssue' }, + 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: 'pinIssue' }, + arguments: [ + { + kind: 'Argument', + name: { kind: 'Name', value: 'issueId' }, + value: { + kind: 'Variable', + name: { kind: 'Name', value: 'issueId' }, + }, + }, + ], + }, + ], + }, + }, + ], +} as unknown as DocumentNode; +export const UnpinIssueDocument = { + kind: 'Document', + definitions: [ + { + kind: 'OperationDefinition', + operation: 'mutation', + name: { kind: 'Name', value: 'UnpinIssue' }, + 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: 'unpinIssue' }, + arguments: [ + { + kind: 'Argument', + name: { kind: 'Name', value: 'issueId' }, + value: { + kind: 'Variable', + name: { kind: 'Name', value: 'issueId' }, + }, + }, + ], + }, + ], + }, + }, + ], +} as unknown as DocumentNode; export const SetIssueReleaseDocument = { kind: 'Document', definitions: [ diff --git a/libs/web/tracker/infrastructure/src/lib/graphql/issues.graphql b/libs/web/tracker/infrastructure/src/lib/graphql/issues.graphql index 6943fc2..3cee1b3 100644 --- a/libs/web/tracker/infrastructure/src/lib/graphql/issues.graphql +++ b/libs/web/tracker/infrastructure/src/lib/graphql/issues.graphql @@ -95,6 +95,20 @@ mutation UnwatchIssue($issueId: ID!) { } } +query MyPins { + myPins { + ...IssueSummaryFields + } +} + +mutation PinIssue($issueId: ID!) { + pinIssue(issueId: $issueId) +} + +mutation UnpinIssue($issueId: ID!) { + unpinIssue(issueId: $issueId) +} + mutation SetIssueRelease($issueId: ID!, $releaseId: ID) { setIssueRelease(issueId: $issueId, releaseId: $releaseId) { ...CommandResultFields 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 9edcbfe..630113c 100644 --- a/libs/web/tracker/presenters/src/lib/pages/issue.page.ts +++ b/libs/web/tracker/presenters/src/lib/pages/issue.page.ts @@ -205,6 +205,21 @@ const MAX_FILE_BYTES = 25 * 1024 * 1024; (execute)="facade.setWatching(!facade.watching())" /> } + @if (project.myRole()) { + + } @if (project.canContribute()) { @if (facade.projects().length) { + @if (myHome.home(); as h) { ({ + id: `i:${i.id}`, + kind: 'issue', + label: `${i.key} · ${i.title}`, + detail: i.assignee?.name ?? '', + url: `/p/${i.key.slice(0, i.key.lastIndexOf('-'))}/issues/${i.key}`, +}); + interface Result { id: string; kind: 'project' | 'issue'; @@ -102,7 +111,11 @@ interface Result { (keydown.enter)="go(result)" > {{ - result.kind === 'project' ? 'Proyecto' : 'Ticket' + result.kind === 'project' + ? 'Proyecto' + : text().trim().length < 2 + ? 'Fijado' + : 'Ticket' }} {{ result.label }} {{ result.detail }} @@ -242,6 +255,7 @@ export class CommandPalette { private readonly router = inject(Router); private readonly projects = inject(ProjectsFacade); private readonly issues = inject(IssuesGateway); + private readonly destroyRef = inject(DestroyRef); private readonly searchInput = viewChild>('search'); @@ -254,9 +268,11 @@ export class CommandPalette { private goTimer: ReturnType | null = null; private readonly foundIssues = signal([]); + /** Mine, shown before typing; read again each time it opens. */ + protected readonly pins = signal([]); protected readonly results = computed(() => { const query = this.text().trim().toLowerCase(); - if (query.length < 2) return []; + if (query.length < 2) return this.pins().map(issueResult); const projects = this.projects .sorted() .filter( @@ -272,14 +288,7 @@ export class CommandPalette { detail: p.key, url: `/p/${p.key}/board`, })); - const issues = this.foundIssues().map((i) => ({ - id: `i:${i.id}`, - kind: 'issue' as const, - label: `${i.key} · ${i.title}`, - detail: i.assignee?.name ?? '', - url: `/p/${i.key.slice(0, i.key.lastIndexOf('-'))}/issues/${i.key}`, - })); - return [...projects, ...issues]; + return [...projects, ...this.foundIssues().map(issueResult)]; }); constructor() { @@ -296,7 +305,7 @@ export class CommandPalette { catchError(() => of([])), ), ), - takeUntilDestroyed(inject(DestroyRef)), + takeUntilDestroyed(this.destroyRef), ) .subscribe((found) => this.foundIssues.set(found)); effect(() => { @@ -349,8 +358,16 @@ export class CommandPalette { } protected open(mode: 'search' | 'help'): void { - if (mode === 'search' && !this.projects.sorted().length) - this.projects.load(); + if (mode === 'search') { + if (!this.projects.sorted().length) this.projects.load(); + this.issues + .pins() + .pipe( + catchError(() => of([])), + takeUntilDestroyed(this.destroyRef), + ) + .subscribe((pins) => this.pins.set(pins)); + } this.mode.set(mode); } diff --git a/libs/web/tracker/presenters/src/lib/ui/home/pinned.ts b/libs/web/tracker/presenters/src/lib/ui/home/pinned.ts new file mode 100644 index 0000000..3f5473e --- /dev/null +++ b/libs/web/tracker/presenters/src/lib/ui/home/pinned.ts @@ -0,0 +1,172 @@ +import { + ChangeDetectionStrategy, + Component, + DestroyRef, + inject, + signal, +} from '@angular/core'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { RouterLink } from '@angular/router'; +import { PiIconComponent } from '@pi/ui'; +import { ToastService } from '@task-stream/web/shared/ui'; +import { + IssuesGateway, + IssueSummary, + StatusCategory, +} from '@task-stream/web/tracker/domain'; + +/** + * The tickets I pinned, at hand on the home page: one strip, only when + * there are some (they are pinned from the ticket's page). + */ +@Component({ + selector: 'ts-pinned', + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [RouterLink, PiIconComponent], + template: ` + @if (pins().length) { +
+

+ + Fijados +

+ +
+ } + `, + host: { '[style.display]': "pins().length ? 'block' : 'none'" }, + styles: ` + .ts-pinned { + display: flex; + align-items: center; + gap: 0.75rem 1rem; + flex-wrap: wrap; + } + h2 { + display: inline-flex; + align-items: center; + gap: 0.375rem; + margin: 0; + color: var(--ts-muted); + font-size: 0.75rem; + font-weight: 600; + letter-spacing: 0.04em; + text-transform: uppercase; + } + ul { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; + margin: 0; + padding: 0; + list-style: none; + } + li { + display: inline-flex; + align-items: center; + max-width: min(100%, 22rem); + border: 1px solid var(--ts-line); + border-radius: 999px; + background: var(--ts-surface); + &:hover, + &:focus-within { + border-color: color-mix(in srgb, var(--ts-brand) 45%, var(--ts-line)); + } + &.is-done .ts-pinned__title { + color: var(--ts-muted); + text-decoration: line-through; + } + } + a { + display: inline-flex; + align-items: baseline; + gap: 0.5rem; + min-width: 0; + padding: 0.375rem 0.25rem 0.375rem 0.875rem; + color: inherit; + font-size: 0.875rem; + text-decoration: none; + } + .ts-pinned__key { + flex: none; + color: var(--ts-muted); + font-family: var(--ts-font-mono); + font-size: 0.75rem; + } + .ts-pinned__title { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + /* A plain glyph, not @pi/ui's sized button. */ + .ts-pinned__unpin { + flex: none; + width: 1.75rem !important; + min-width: 0 !important; + height: 1.75rem !important; + margin-right: 0.25rem; + padding: 0 !important; + border: 0; + border-radius: 50%; + background: transparent; + color: var(--ts-muted); + font-size: 1.125rem; + line-height: 1; + cursor: pointer; + &:hover, + &:focus-visible { + background: var(--ts-sunken); + color: var(--ts-ink); + } + } + `, +}) +export class PinnedComponent { + private readonly gateway = inject(IssuesGateway); + private readonly toast = inject(ToastService); + private readonly destroyRef = inject(DestroyRef); + protected readonly pins = signal([]); + protected readonly done = StatusCategory.DONE; + + constructor() { + this.gateway + .pins() + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe({ next: (pins) => this.pins.set(pins), error: () => null }); + } + + protected projectOf(key: string): string { + return key.slice(0, key.lastIndexOf('-')); + } + + protected unpin(pin: IssueSummary): void { + this.pins.update((list) => list.filter((p) => p.id !== pin.id)); + this.gateway + .pin(pin.id, false) + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe({ + error: () => { + this.toast.error(`No se pudo quitar ${pin.key} de fijados`); + this.pins.update((list) => [pin, ...list]); + }, + }); + } +}