From bec39da46d993aa9f58184ae550d8b44a7a90202 Mon Sep 17 00:00:00 2001 From: Josep Piguillem Date: Thu, 8 Oct 2026 18:47:30 +0200 Subject: [PATCH] feat: a trash for tickets Deleting a ticket now sends it to the trash, for whoever opened it or an admin: it disappears from boards, searches and statistics, cannot change, and comes back from the ticket page or the trash page for 30 days. After that it is deleted for good with the usual cascade. Co-Authored-By: Claude Opus 5.5 --- apps/api/openapi.json | 293 +++++++++++++++ apps/api/schema.gql | 14 + apps/mcp/src/app/api/taskstream.schema.ts | 255 ++++++++++++- apps/web/src/app/app.routes.spec.ts | 1 + docs/01-vision-general.md | 1 + docs/03-api.md | 5 + docs/07-despliegue.md | 1 + .../src/lib/commands/command-handlers.ts | 2 + .../src/lib/commands/commands.spec.ts | 19 + .../application/src/lib/commands/index.ts | 1 + .../application/src/lib/commands/trash.ts | 103 ++++++ .../src/lib/commands/write-access.ts | 4 + .../lib/event-handlers/activity-summary.ts | 6 + .../event-handlers/activity.event-handler.ts | 4 + .../src/lib/event-handlers/event-handlers.ts | 2 + .../issue-trash.event-handler.ts | 27 ++ .../application/src/lib/issues.service.ts | 18 + .../src/lib/ports/find-issues.repository.ts | 4 + .../src/lib/queries/get-trash.query.ts | 29 ++ .../application/src/lib/queries/index.ts | 1 + .../src/lib/queries/query-handlers.ts | 2 + .../tracker/domain/src/lib/events/index.ts | 2 + .../src/lib/events/issue-restored.event.ts | 10 + .../src/lib/events/issue-trashed.event.ts | 10 + .../domain/src/lib/issue/issue.spec.ts | 13 + .../api/tracker/domain/src/lib/issue/issue.ts | 35 ++ .../src/lib/read-models/issue.read-model.ts | 2 + .../src/lib/mongo/find.repositories.ts | 55 ++- .../src/lib/mongo/read-model.schemas.ts | 2 + .../src/lib/notifications/trash.scheduler.ts | 60 +++ .../src/lib/tracker-infrastructure.module.ts | 2 + .../src/lib/graphql/issue.resolver.ts | 30 ++ .../presenters/src/lib/graphql/types.ts | 5 + .../src/lib/http/dto/responses.dto.ts | 7 + .../src/lib/http/issues.controller.ts | 41 +++ .../src/lib/events/issue.events.ts | 1 + .../src/lib/facades/issue.facade.ts | 7 + .../application/src/lib/stores/issue.store.ts | 16 + .../tracker/domain/src/lib/models/models.ts | 2 + .../tracker/domain/src/lib/ports/gateways.ts | 5 + .../lib/adapters/graphql-issues.gateway.ts | 21 ++ .../src/lib/graphql/fragments.graphql | 1 + .../src/lib/graphql/generated.ts | 348 ++++++++++++++++++ .../src/lib/graphql/issues.graphql | 18 + .../presenters/src/lib/pages/backlog.page.ts | 8 + .../presenters/src/lib/pages/issue.page.scss | 11 + .../presenters/src/lib/pages/issue.page.ts | 40 ++ .../presenters/src/lib/pages/trash.page.ts | 130 +++++++ .../presenters/src/lib/tracker.routes.ts | 6 + 49 files changed, 1671 insertions(+), 9 deletions(-) create mode 100644 libs/api/tracker/application/src/lib/commands/trash.ts create mode 100644 libs/api/tracker/application/src/lib/event-handlers/issue-trash.event-handler.ts create mode 100644 libs/api/tracker/application/src/lib/queries/get-trash.query.ts create mode 100644 libs/api/tracker/domain/src/lib/events/issue-restored.event.ts create mode 100644 libs/api/tracker/domain/src/lib/events/issue-trashed.event.ts create mode 100644 libs/api/tracker/infrastructure/src/lib/notifications/trash.scheduler.ts create mode 100644 libs/web/tracker/presenters/src/lib/pages/trash.page.ts diff --git a/apps/api/openapi.json b/apps/api/openapi.json index 2b91aa0..0bcf62e 100644 --- a/apps/api/openapi.json +++ b/apps/api/openapi.json @@ -2749,6 +2749,106 @@ "tags": [ "Issues" ] + }, + "delete": { + "description": "Whoever opened it, or an owner/admin. Hidden everywhere; restore it within 30 days or it is deleted for good.", + "operationId": "IssuesController_trash", + "parameters": [ + { + "name": "issue", + "required": true, + "in": "path", + "description": "Ticket id or key", + "schema": { + "example": "WEB-12", + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CommandResultDto" + } + } + } + }, + "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": "Move the ticket to the trash", + "tags": [ + "Issues" + ] } }, "/api/issues/{issue}/status": { @@ -3085,6 +3185,192 @@ ] } }, + "/api/issues/{issue}/restore": { + "post": { + "operationId": "IssuesController_restore", + "parameters": [ + { + "name": "issue", + "required": true, + "in": "path", + "description": "Ticket id or key", + "schema": { + "example": "WEB-12", + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CommandResultDto" + } + } + } + }, + "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": "Bring the ticket back from the trash", + "tags": [ + "Issues" + ] + } + }, + "/api/projects/{project}/trash": { + "get": { + "operationId": "IssuesController_trashed", + "parameters": [ + { + "name": "project", + "required": true, + "in": "path", + "description": "Project id or key", + "schema": { + "example": "WEB", + "type": "string" + } + } + ], + "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": "The project's tickets in the trash, newest first", + "tags": [ + "Issues" + ] + } + }, "/api/issues/bulk": { "post": { "description": "Each ticket goes through the normal rules (permissions, workflow, open sprints and releases) and may refuse on its own; the answer says which.", @@ -10025,6 +10311,12 @@ "$ref": "#/components/schemas/ChecklistItemDto" } }, + "trashedAt": { + "type": "string", + "nullable": true, + "format": "date-time", + "description": "In the trash since then; null when it is not" + }, "devLinks": { "description": "Commits, branches and pull requests that mention it", "type": "array", @@ -10076,6 +10368,7 @@ "parent", "rank", "checklist", + "trashedAt", "devLinks", "commentCount", "createdAt", diff --git a/apps/api/schema.gql b/apps/api/schema.gql index 2e688ca..1949be5 100644 --- a/apps/api/schema.gql +++ b/apps/api/schema.gql @@ -439,6 +439,9 @@ type Issue { storyPoints: Int title: String! + """In the trash since then; null when it is not""" + trashedAt: String + """A ticket type id of the project's settings""" type: String! updatedAt: String! @@ -656,6 +659,9 @@ type Mutation { removeChecklistItem(issueId: ID!, itemId: ID!): CommandResult! removeProjectMember(projectId: ID!, userId: ID!): CommandResult! + """Back from the trash""" + restoreIssue(issueId: ID!): CommandResult! + """Save the current board filters under a name (members of the project)""" saveFilter(input: SaveFilterInput!): ID! @@ -675,6 +681,11 @@ type Mutation { """Send a ping now and tell how it went""" testWebhook(webhookId: ID!): WebhookDelivery! + + """ + To the trash (whoever opened it, or an owner/admin): hidden everywhere, deleted for good after 30 days + """ + trashIssue(issueId: ID!): CommandResult! unlinkIssues(input: LinkIssuesInput!): CommandResult! """Stop following a ticket""" @@ -937,6 +948,9 @@ type Query { """ sprints(projectId: ID!): [Sprint!]! + """A project's tickets in the trash, newest first""" + trash(projectId: ID!): [Issue!]! + """Planned releases of all my projects, soonest target date first""" upcomingReleases(limit: Int! = 10): [Release!]! user(id: String!): User diff --git a/apps/mcp/src/app/api/taskstream.schema.ts b/apps/mcp/src/app/api/taskstream.schema.ts index a85d885..394fa99 100644 --- a/apps/mcp/src/app/api/taskstream.schema.ts +++ b/apps/mcp/src/app/api/taskstream.schema.ts @@ -394,7 +394,11 @@ export interface paths { get: operations["IssuesController_findOne"]; put?: never; post?: never; - delete?: never; + /** + * Move the ticket to the trash + * @description Whoever opened it, or an owner/admin. Hidden everywhere; restore it within 30 days or it is deleted for good. + */ + delete: operations["IssuesController_trash"]; options?: never; head?: never; /** Edit title, description, type, priority, area, labels… */ @@ -455,6 +459,40 @@ export interface paths { patch?: never; trace?: never; }; + "/api/issues/{issue}/restore": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Bring the ticket back from the trash */ + post: operations["IssuesController_restore"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/projects/{project}/trash": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** The project's tickets in the trash, newest first */ + get: operations["IssuesController_trashed"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/issues/bulk": { parameters: { query?: never; @@ -1674,6 +1712,11 @@ export interface components { /** @description Place in the backlog and its board column (ascending, as text) */ rank: string; checklist: components["schemas"]["ChecklistItemDto"][]; + /** + * Format: date-time + * @description In the trash since then; null when it is not + */ + trashedAt: string | null; /** @description Commits, branches and pull requests that mention it */ devLinks: components["schemas"]["DevLinkDto"][]; commentCount: number; @@ -4060,6 +4103,82 @@ export interface operations { }; }; }; + IssuesController_trash: { + parameters: { + query?: never; + header?: never; + path: { + /** @description Ticket id or key */ + issue: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["CommandResultDto"]; + }; + }; + /** @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_update: { parameters: { query?: never; @@ -4380,6 +4499,140 @@ export interface operations { }; }; }; + IssuesController_restore: { + parameters: { + query?: never; + header?: never; + path: { + /** @description Ticket id or key */ + issue: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["CommandResultDto"]; + }; + }; + /** @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_trashed: { + parameters: { + query?: never; + header?: never; + path: { + /** @description Project id or key */ + project: string; + }; + 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_bulkUpdate: { parameters: { query?: never; diff --git a/apps/web/src/app/app.routes.spec.ts b/apps/web/src/app/app.routes.spec.ts index 88d143d..42f1b52 100644 --- a/apps/web/src/app/app.routes.spec.ts +++ b/apps/web/src/app/app.routes.spec.ts @@ -32,6 +32,7 @@ describe('appRoutes', () => { 'sprints', 'roadmap', 'epics', + 'trash', 'releases', 'dashboard', 'analytics', diff --git a/docs/01-vision-general.md b/docs/01-vision-general.md index eef56a3..3ddb481 100644 --- a/docs/01-vision-general.md +++ b/docs/01-vision-general.md @@ -20,6 +20,7 @@ | 🎲 Estimación | Puntos por ticket (escala 0-1-2-3-5-8-13-21) en la ficha, las tarjetas, las columnas y el backlog; velocidad por sprint y aviso si un sprint planificado supera la media | | 🏃 Sprints | Iteraciones con fechas y objetivo, una en curso por proyecto, con burndown (tickets o puntos) y traspaso de lo pendiente al cerrar | | 📋 Backlog | Lo pendiente de empezar, en el orden en que se va a coger; se reordena arrastrando y el tablero sigue ese orden | +| 🗑️ Papelera | Borrar un ticket lo manda a la papelera (quien lo abrió o un admin); se recupera durante 30 días y luego se borra del todo | | ☑️ En bloque | Selecciona varios tickets del backlog y cámbiales a la vez estado, persona, sprint, prioridad o etiqueta | | ⌨️ Atajos y búsqueda | Ctrl/⌘ K o / busca tickets y proyectos de todos tus proyectos; `g b`, `g k`, `g s`, `g r` navegan, `c` crea, `e` edita, `?` los lista | | 🗺️ Hoja de ruta | Las épicas en el tiempo (fecha de inicio y límite), con los sprints y las releases como referencias | diff --git a/docs/03-api.md b/docs/03-api.md index 7c9cf52..3ba4a39 100644 --- a/docs/03-api.md +++ b/docs/03-api.md @@ -167,6 +167,11 @@ stateDiagram-v2 actor `system`); el mismo enlace solo vuelve a contar si cambia (una PR fusionada). Proyecto desconocido, integración apagada o firma mala responden igual: 403. El secreto vive en MongoDB (`git_integrations`), no en el event store. +- **Papelera**: `trashIssue` / `DELETE /api/issues/{issue}` manda un ticket a la papelera (quien lo abrió o un owner/ + admin; `IssueTrashedEvent`): desaparece del tablero, las búsquedas, las estadísticas y el progreso, y no se puede + cambiar hasta recuperarlo (`restoreIssue` / `POST …/restore`, `IssueRestoredEvent`). `trash(projectId)` / + `GET /api/projects/{project}/trash` los lista. A los `TRASH_DAYS` días (30) se borra del todo, con la cascada de + siempre: comentarios, enlaces y subtareas de vuelta al primer nivel. - **Edición en bloque**: `bulkUpdateIssues` / `POST /api/issues/bulk` aplica el mismo cambio (estado, persona o `unassign`, sprint o `noSprint`, release o `noRelease`, prioridad, etiquetas que añadir) a hasta 100 tickets. Cada uno pasa por los comandos normales (permisos, flujo, sprints y releases abiertos) y puede negarse sin parar al resto; diff --git a/docs/07-despliegue.md b/docs/07-despliegue.md index b13ad4d..06bbd00 100644 --- a/docs/07-despliegue.md +++ b/docs/07-despliegue.md @@ -101,6 +101,7 @@ donde los lee Compose. | `WEBHOOKS_ALLOW_PRIVATE` | `false` | Deja que los webhooks salientes llamen a direcciones de esta máquina o de su red privada (apagado, un webhook podría alcanzar servicios internos) | | `REMINDERS_ENABLED` | `true` | Avisa cada hora de los tickets que vencen mañana, hoy o ya han vencido | | `REMINDERS_TIMEZONE` | `Europe/Madrid` | Zona horaria en la que cambia el día para los recordatorios | +| `TRASH_DAYS` | `30` | Días que un ticket pasa en la papelera antes de borrarse del todo | La web no tiene configuración de entorno: llama a la API en su mismo origen (`/api`, `/graphql`). diff --git a/libs/api/tracker/application/src/lib/commands/command-handlers.ts b/libs/api/tracker/application/src/lib/commands/command-handlers.ts index e1e8ad6..7015e2c 100644 --- a/libs/api/tracker/application/src/lib/commands/command-handlers.ts +++ b/libs/api/tracker/application/src/lib/commands/command-handlers.ts @@ -33,6 +33,7 @@ import { RankIssueCommandHandler } from './rank-issue.command-handler'; import { WriteReleaseNotesCommandHandler } from './write-release-notes.command-handler'; import { SPRINT_COMMAND_HANDLERS } from './sprint.command-handlers'; import { BulkUpdateIssuesCommandHandler } from './bulk-update-issues'; +import { TRASH_HANDLERS } from './trash'; import { DetachFromParentCommandHandler } from './detach-from-parent.command-handler'; import { AddChecklistItemCommandHandler, @@ -77,6 +78,7 @@ export const COMMAND_HANDLERS = [ WriteReleaseNotesCommandHandler, ...SPRINT_COMMAND_HANDLERS, BulkUpdateIssuesCommandHandler, + ...TRASH_HANDLERS, AddChecklistItemCommandHandler, UpdateChecklistItemCommandHandler, RemoveChecklistItemCommandHandler, diff --git a/libs/api/tracker/application/src/lib/commands/commands.spec.ts b/libs/api/tracker/application/src/lib/commands/commands.spec.ts index 7076fd5..24a107e 100644 --- a/libs/api/tracker/application/src/lib/commands/commands.spec.ts +++ b/libs/api/tracker/application/src/lib/commands/commands.spec.ts @@ -78,6 +78,7 @@ import { DeleteCommentCommand } from './delete-comment.command'; import { PublishReleaseCommand } from './publish-release.command'; import { SetIssueReleaseCommand } from './set-issue-release.command'; import { WriteAccess } from './write-access'; +import { RestoreIssueCommand, TrashIssueCommand } from './trash'; import { BulkResult, BulkUpdateIssuesCommand } from './bulk-update-issues'; import { UpdateProjectSettingsCommand } from './update-project-settings.command'; import { ReadAccess } from '../queries/read-access'; @@ -666,6 +667,24 @@ describe('tracker commands (event sourced)', () => { ).rejects.toThrow(/Nothing to change/); }); + it('sends tickets to the trash and back; trashed ones cannot change', async () => { + const { run, rehydrator } = await setup(); + const projectId = await projectWithTeam(run); + const issue = await run( + new CreateIssueCommand('qa', projectId, IssueType.BUG, 'Crash', {}), + ); + await expect(run(new TrashIssueCommand('dev', issue.id))).rejects.toThrow( + ForbiddenError, + ); + await run(new TrashIssueCommand('qa', issue.id)); + await expect( + run(new AssignIssueCommand('owner', issue.id, 'dev')), + ).rejects.toThrow(/trash/); + await run(new RestoreIssueCommand('owner', issue.id)); + await run(new AssignIssueCommand('owner', issue.id, 'dev')); + expect((await rehydrator.rehydrate(issue.id, Issue)).trashedAt).toBeNull(); + }); + it('does not let viewers modify tickets', async () => { const { run } = await setup(); const projectId = await projectWithTeam(run); diff --git a/libs/api/tracker/application/src/lib/commands/index.ts b/libs/api/tracker/application/src/lib/commands/index.ts index 410fded..f997645 100644 --- a/libs/api/tracker/application/src/lib/commands/index.ts +++ b/libs/api/tracker/application/src/lib/commands/index.ts @@ -36,3 +36,4 @@ export * from './rank-issue.command'; export * from './sprint.commands'; export * from './write-release-notes.command'; export * from './bulk-update-issues'; +export * from './trash'; diff --git a/libs/api/tracker/application/src/lib/commands/trash.ts b/libs/api/tracker/application/src/lib/commands/trash.ts new file mode 100644 index 0000000..c65f9fa --- /dev/null +++ b/libs/api/tracker/application/src/lib/commands/trash.ts @@ -0,0 +1,103 @@ +import { + Command, + CommandBus, + CommandHandler, + ICommandHandler, +} from '@nestjs/cqrs'; +import { SYSTEM_ACTOR_ID } from '@task-stream/api/tracker/domain'; +import { FindIssuesRepository } from '../ports/find-issues.repository'; +import { CommandResult, ack } from './command-result'; +import { DeleteIssueCommand } from './delete-issue.command'; +import { WriteAccess } from './write-access'; + +/** Days a ticket stays in the trash before it is deleted for good. */ +export const TRASH_DAYS = 30; + +export class TrashIssueCommand extends Command { + constructor( + public readonly actorId: string, + public readonly issueId: string, + ) { + super(); + } +} + +export class RestoreIssueCommand extends Command { + constructor( + public readonly actorId: string, + public readonly issueId: string, + ) { + super(); + } +} + +/** Scheduler only: deletes for good what went to the trash before `before`. */ +export class EmptyTrashCommand extends Command { + constructor(public readonly before: string) { + super(); + } +} + +@CommandHandler(TrashIssueCommand) +export class TrashIssueCommandHandler + implements ICommandHandler +{ + constructor(private readonly access: WriteAccess) {} + + async execute(command: TrashIssueCommand): Promise { + const { issue, project } = await this.access.issueForContributor( + command.issueId, + command.actorId, + ); + const role = project.roleOf(command.actorId); + issue.trash(command.actorId, role === 'OWNER' || role === 'ADMIN'); + await issue.commitAndWait(); + return ack(issue); + } +} + +@CommandHandler(RestoreIssueCommand) +export class RestoreIssueCommandHandler + implements ICommandHandler +{ + constructor(private readonly access: WriteAccess) {} + + async execute(command: RestoreIssueCommand): Promise { + const issue = await this.access.issue(command.issueId); + const project = await this.access.project(issue.projectId); + project.assertCanContribute(command.actorId); + const role = project.roleOf(command.actorId); + issue.restore(command.actorId, role === 'OWNER' || role === 'ADMIN'); + await issue.commitAndWait(); + return ack(issue); + } +} + +/** + * Each deletion is the usual one: comments purged, links dropped and + * subtasks back to the top, by the sagas. + */ +@CommandHandler(EmptyTrashCommand) +export class EmptyTrashCommandHandler + implements ICommandHandler +{ + constructor( + private readonly findIssues: FindIssuesRepository, + private readonly commandBus: CommandBus, + ) {} + + async execute(command: EmptyTrashCommand): Promise { + const old = await this.findIssues.findTrashedBefore(command.before); + for (const issue of old) + await this.commandBus.execute( + new DeleteIssueCommand(SYSTEM_ACTOR_ID, issue.id), + ); + return old.length; + } +} + +export const TRASH_HANDLERS = [ + TrashIssueCommandHandler, + RestoreIssueCommandHandler, + EmptyTrashCommandHandler, +]; diff --git a/libs/api/tracker/application/src/lib/commands/write-access.ts b/libs/api/tracker/application/src/lib/commands/write-access.ts index 21a9d56..43902c9 100644 --- a/libs/api/tracker/application/src/lib/commands/write-access.ts +++ b/libs/api/tracker/application/src/lib/commands/write-access.ts @@ -67,6 +67,10 @@ export class WriteAccess { const issue = await this.issue(issueId); const project = await this.project(issue.projectId); project.assertCanContribute(actorId); + if (issue.trashedAt) + throw new BusinessRuleError( + `${issue.key} is in the trash: restore it to change it`, + ); return { issue, project }; } diff --git a/libs/api/tracker/application/src/lib/event-handlers/activity-summary.ts b/libs/api/tracker/application/src/lib/event-handlers/activity-summary.ts index d933e6d..e4e8921 100644 --- a/libs/api/tracker/application/src/lib/event-handlers/activity-summary.ts +++ b/libs/api/tracker/application/src/lib/event-handlers/activity-summary.ts @@ -10,6 +10,8 @@ import { IssueChecklistItemUpdatedEvent, IssueParentChangedEvent, IssueDevLinkRecordedEvent, + IssueTrashedEvent, + IssueRestoredEvent, IssueCreatedEvent, IssueLinkedEvent, IssueLinkType, @@ -163,6 +165,10 @@ export function describeEvent( ? `${actor} metió ${issueKey} en ${sprint}` : `${actor} sacó ${issueKey} de ${sprint}`; } + if (event instanceof IssueTrashedEvent) + return `${actor} movió ${issueKey} a la papelera`; + if (event instanceof IssueRestoredEvent) + return `${actor} recuperó ${issueKey} de la papelera`; if (event instanceof IssueDevLinkRecordedEvent) { const { kind, ref, state, author } = event.link; const who = author ? ` (${author})` : ''; diff --git a/libs/api/tracker/application/src/lib/event-handlers/activity.event-handler.ts b/libs/api/tracker/application/src/lib/event-handlers/activity.event-handler.ts index 6d45ccf..ec56dbd 100644 --- a/libs/api/tracker/application/src/lib/event-handlers/activity.event-handler.ts +++ b/libs/api/tracker/application/src/lib/event-handlers/activity.event-handler.ts @@ -12,6 +12,8 @@ import { IssueChecklistItemUpdatedEvent, IssueParentChangedEvent, IssueDevLinkRecordedEvent, + IssueTrashedEvent, + IssueRestoredEvent, IssueCreatedEvent, IssueLinkedEvent, IssueReleaseChangedEvent, @@ -70,6 +72,8 @@ import { versionOf } from './read-model-notifier'; IssueAttachmentRemovedEvent, IssueParentChangedEvent, IssueDevLinkRecordedEvent, + IssueTrashedEvent, + IssueRestoredEvent, IssueChecklistItemAddedEvent, IssueChecklistItemUpdatedEvent, IssueChecklistItemRemovedEvent, diff --git a/libs/api/tracker/application/src/lib/event-handlers/event-handlers.ts b/libs/api/tracker/application/src/lib/event-handlers/event-handlers.ts index 3e96a52..82ad89a 100644 --- a/libs/api/tracker/application/src/lib/event-handlers/event-handlers.ts +++ b/libs/api/tracker/application/src/lib/event-handlers/event-handlers.ts @@ -34,6 +34,7 @@ import { IssueRankedEventHandler } from './issue-ranked.event-handler'; import { SprintEventHandler } from './sprint.event-handler'; import { ReleaseNotesWrittenEventHandler } from './release-notes-written.event-handler'; import { IssueDevLinkRecordedEventHandler } from './issue-dev-link-recorded.event-handler'; +import { IssueTrashEventHandler } from './issue-trash.event-handler'; import { IssueSprintChangedEventHandler } from './issue-sprint-changed.event-handler'; /** Read-model projections (MongoDB) + realtime, fed by the EventsBridge. */ @@ -73,6 +74,7 @@ export const EVENT_HANDLERS = [ SprintEventHandler, ReleaseNotesWrittenEventHandler, IssueDevLinkRecordedEventHandler, + IssueTrashEventHandler, IssueSprintChangedEventHandler, ]; diff --git a/libs/api/tracker/application/src/lib/event-handlers/issue-trash.event-handler.ts b/libs/api/tracker/application/src/lib/event-handlers/issue-trash.event-handler.ts new file mode 100644 index 0000000..2510260 --- /dev/null +++ b/libs/api/tracker/application/src/lib/event-handlers/issue-trash.event-handler.ts @@ -0,0 +1,27 @@ +import { EventsHandler, IEventHandler } from '@nestjs/cqrs'; +import { + IssueRestoredEvent, + IssueTrashedEvent, +} from '@task-stream/api/tracker/domain'; +import { UpsertMaterializedIssueRepository } from '../ports/upsert-materialized-issue.repository'; +import { ReadModelNotifier, versionOf } from './read-model-notifier'; + +@EventsHandler(IssueTrashedEvent, IssueRestoredEvent) +export class IssueTrashEventHandler + implements IEventHandler +{ + constructor( + private readonly upsertMaterializedIssueRepository: UpsertMaterializedIssueRepository, + private readonly notifier: ReadModelNotifier, + ) {} + + async handle(event: IssueTrashedEvent | IssueRestoredEvent) { + await this.upsertMaterializedIssueRepository.upsert({ + id: event.aggregateId, + trashedAt: event instanceof IssueTrashedEvent ? event.occurredAt : null, + updatedAt: event.occurredAt, + version: versionOf(event), + }); + await this.notifier.issue(event.aggregateId, event); + } +} diff --git a/libs/api/tracker/application/src/lib/issues.service.ts b/libs/api/tracker/application/src/lib/issues.service.ts index f8bd2ac..bc205d3 100644 --- a/libs/api/tracker/application/src/lib/issues.service.ts +++ b/libs/api/tracker/application/src/lib/issues.service.ts @@ -16,6 +16,8 @@ import { DetachFileCommand } from './commands/detach-file.command'; import { WatchIssueCommand } from './commands/watch-issue.command'; import { SetIssueParentCommand } from './commands/set-issue-parent.command'; import { RankIssueCommand } from './commands/rank-issue.command'; +import { RestoreIssueCommand, TrashIssueCommand } from './commands/trash'; +import { GetTrashQuery } from './queries/get-trash.query'; import { BulkChange, BulkResult, @@ -247,6 +249,22 @@ export class IssuesService { ); } + /** To the trash (whoever opened it, or a manager); back with `restore`. */ + trash(actorId: string, issueId: string): Promise { + return this.commandBus.execute(new TrashIssueCommand(actorId, issueId)); + } + + restore(actorId: string, issueId: string): Promise { + return this.commandBus.execute(new RestoreIssueCommand(actorId, issueId)); + } + + trashed( + viewerId: string | null, + projectId: string, + ): Promise { + return this.queryBus.execute(new GetTrashQuery(viewerId, projectId)); + } + addChecklistItem( actorId: string, issueId: string, diff --git a/libs/api/tracker/application/src/lib/ports/find-issues.repository.ts b/libs/api/tracker/application/src/lib/ports/find-issues.repository.ts index 9bb90cc..2ad2839 100644 --- a/libs/api/tracker/application/src/lib/ports/find-issues.repository.ts +++ b/libs/api/tracker/application/src/lib/ports/find-issues.repository.ts @@ -66,6 +66,10 @@ export abstract class FindIssuesRepository { abstract findOpenAssignedTo(userId: string): Promise; /** Unfinished tickets with someone on them, due on or before `day` (YYYY-MM-DD). */ abstract findDueBy(day: string): Promise; + /** The project's tickets in the trash, newest first. */ + abstract findTrashed(projectId: string): Promise; + /** Tickets that went to the trash before `before` (ISO), any project. */ + abstract findTrashedBefore(before: string): Promise; /** Every ticket of a project (statistics). */ abstract findByProject(projectId: string): Promise; /** Top-level tickets with others hanging from them, by key. */ diff --git a/libs/api/tracker/application/src/lib/queries/get-trash.query.ts b/libs/api/tracker/application/src/lib/queries/get-trash.query.ts new file mode 100644 index 0000000..5ec982f --- /dev/null +++ b/libs/api/tracker/application/src/lib/queries/get-trash.query.ts @@ -0,0 +1,29 @@ +import { IQueryHandler, Query, QueryHandler } from '@nestjs/cqrs'; +import { IssueReadModel } from '@task-stream/api/tracker/domain'; +import { FindIssuesRepository } from '../ports/find-issues.repository'; +import { ReadAccess } from './read-access'; + +/** A project's tickets in the trash, newest first. */ +export class GetTrashQuery extends Query { + constructor( + public readonly viewerId: string | null, + public readonly projectId: string, + ) { + super(); + } +} + +@QueryHandler(GetTrashQuery) +export class GetTrashQueryHandler + implements IQueryHandler +{ + constructor( + private readonly findIssues: FindIssuesRepository, + private readonly access: ReadAccess, + ) {} + + async execute(query: GetTrashQuery): Promise { + await this.access.assertCanView(query.projectId, query.viewerId); + return this.findIssues.findTrashed(query.projectId); + } +} diff --git a/libs/api/tracker/application/src/lib/queries/index.ts b/libs/api/tracker/application/src/lib/queries/index.ts index acf447f..73b1b45 100644 --- a/libs/api/tracker/application/src/lib/queries/index.ts +++ b/libs/api/tracker/application/src/lib/queries/index.ts @@ -20,3 +20,4 @@ export * from './get-attachment.query'; export * from './get-epics.query'; export * from './sprint.queries'; export * from './get-release-notes-draft.query'; +export * from './get-trash.query'; diff --git a/libs/api/tracker/application/src/lib/queries/query-handlers.ts b/libs/api/tracker/application/src/lib/queries/query-handlers.ts index c50508c..858b559 100644 --- a/libs/api/tracker/application/src/lib/queries/query-handlers.ts +++ b/libs/api/tracker/application/src/lib/queries/query-handlers.ts @@ -19,6 +19,7 @@ import { SearchIssuesQueryHandler } from './search-issues.query-handler'; import { GetEpicsQueryHandler } from './get-epics.query-handler'; import { SPRINT_QUERY_HANDLERS } from './sprint.queries'; import { GetReleaseNotesDraftQueryHandler } from './get-release-notes-draft.query'; +import { GetTrashQueryHandler } from './get-trash.query'; import { SearchMyIssuesQueryHandler } from './search-my-issues.query-handler'; export const QUERY_HANDLERS = [ @@ -42,6 +43,7 @@ export const QUERY_HANDLERS = [ GetEpicsQueryHandler, ...SPRINT_QUERY_HANDLERS, GetReleaseNotesDraftQueryHandler, + GetTrashQueryHandler, SearchMyIssuesQueryHandler, GetAttachmentQueryHandler, ]; diff --git a/libs/api/tracker/domain/src/lib/events/index.ts b/libs/api/tracker/domain/src/lib/events/index.ts index 2d28c22..924528c 100644 --- a/libs/api/tracker/domain/src/lib/events/index.ts +++ b/libs/api/tracker/domain/src/lib/events/index.ts @@ -39,3 +39,5 @@ export * from './sprint-deleted.event'; export * from './issue-sprint-changed.event'; export * from './release-notes-written.event'; export * from './issue-dev-link-recorded.event'; +export * from './issue-trashed.event'; +export * from './issue-restored.event'; diff --git a/libs/api/tracker/domain/src/lib/events/issue-restored.event.ts b/libs/api/tracker/domain/src/lib/events/issue-restored.event.ts new file mode 100644 index 0000000..58ad74e --- /dev/null +++ b/libs/api/tracker/domain/src/lib/events/issue-restored.event.ts @@ -0,0 +1,10 @@ +import { AutowiredEvent } from '@task-stream/api/shared/domain'; +import { TrackerEvent } from './tracker.event'; + +/** A ticket came back from the trash. */ +@AutowiredEvent +export class IssueRestoredEvent extends TrackerEvent { + constructor(actorId: string, issueId: string, projectId: string) { + super(actorId, issueId, projectId); + } +} diff --git a/libs/api/tracker/domain/src/lib/events/issue-trashed.event.ts b/libs/api/tracker/domain/src/lib/events/issue-trashed.event.ts new file mode 100644 index 0000000..b45a87a --- /dev/null +++ b/libs/api/tracker/domain/src/lib/events/issue-trashed.event.ts @@ -0,0 +1,10 @@ +import { AutowiredEvent } from '@task-stream/api/shared/domain'; +import { TrackerEvent } from './tracker.event'; + +/** A ticket went to the trash (it can come back for a while). */ +@AutowiredEvent +export class IssueTrashedEvent extends TrackerEvent { + constructor(actorId: string, issueId: string, projectId: string) { + super(actorId, issueId, projectId); + } +} diff --git a/libs/api/tracker/domain/src/lib/issue/issue.spec.ts b/libs/api/tracker/domain/src/lib/issue/issue.spec.ts index 8be9403..dc173b2 100644 --- a/libs/api/tracker/domain/src/lib/issue/issue.spec.ts +++ b/libs/api/tracker/domain/src/lib/issue/issue.spec.ts @@ -279,6 +279,19 @@ describe('Issue', () => { }); }); + describe('trash', () => { + it('goes to the trash and back, by its reporter or a manager', () => { + const issue = newIssue(); + expect(() => issue.trash('u-dev', false)).toThrow(ForbiddenError); + issue.trash('u-qa', false); + issue.trash('u-qa', false); + expect(replay(issue, Issue).rebuilt.trashedAt).not.toBeNull(); + issue.restore('u-pm', true); + expect(issue.trashedAt).toBeNull(); + expect(issue.getUncommittedEvents()).toHaveLength(3); + }); + }); + describe('dates', () => { it('plans a start and a due date, never the wrong way round', () => { const issue = newIssue(); diff --git a/libs/api/tracker/domain/src/lib/issue/issue.ts b/libs/api/tracker/domain/src/lib/issue/issue.ts index e0f3ab5..41f4775 100644 --- a/libs/api/tracker/domain/src/lib/issue/issue.ts +++ b/libs/api/tracker/domain/src/lib/issue/issue.ts @@ -15,6 +15,8 @@ import { IssueDeletedEvent } from '../events/issue-deleted.event'; import { IssueLinkedEvent } from '../events/issue-linked.event'; import { IssueParentChangedEvent } from '../events/issue-parent-changed.event'; import { IssueDevLinkRecordedEvent } from '../events/issue-dev-link-recorded.event'; +import { IssueRestoredEvent } from '../events/issue-restored.event'; +import { IssueTrashedEvent } from '../events/issue-trashed.event'; import { IssueRankedEvent } from '../events/issue-ranked.event'; import { IssueReleaseChangedEvent } from '../events/issue-release-changed.event'; import { IssueSprintChangedEvent } from '../events/issue-sprint-changed.event'; @@ -122,6 +124,8 @@ export class Issue extends VersionedAggregateRoot implements IssueProps { public sprintId: string | null = null; /** Commits, branches and pull requests that mention it. */ public devLinks: DevLink[] = []; + /** In the trash since then (null: not in it). */ + public trashedAt: string | null = null; public createdAt!: string; public updatedAt!: string; public deleted = false; @@ -454,6 +458,29 @@ export class Issue extends VersionedAggregateRoot implements IssueProps { return item; } + /** + * Sends it to the trash: hidden everywhere, back with `restore` until the + * trash is emptied. Whoever opened it, or whoever manages the project. + */ + trash(actorId: string, canManage: boolean): void { + if (this.trashedAt) return; + this.assertMayTrash(actorId, canManage); + this.apply(new IssueTrashedEvent(actorId, this.id, this.projectId)); + } + + restore(actorId: string, canManage: boolean): void { + if (!this.trashedAt) return; + this.assertMayTrash(actorId, canManage); + this.apply(new IssueRestoredEvent(actorId, this.id, this.projectId)); + } + + private assertMayTrash(actorId: string, canManage: boolean): void { + if (actorId !== this.reporterId && !canManage) + throw new ForbiddenError( + 'Only whoever opened a ticket, or a project admin, can delete it', + ); + } + /** Deletes the ticket (its project was deleted). Idempotent. */ delete(actorId: string): void { if (this.deleted) return; @@ -565,6 +592,14 @@ export class Issue extends VersionedAggregateRoot implements IssueProps { this.updatedAt = event.occurredAt; } + onIssueTrashedEvent(event: IssueTrashedEvent): void { + this.trashedAt = event.occurredAt; + } + + onIssueRestoredEvent(): void { + this.trashedAt = null; + } + onIssueDeletedEvent(): void { this.deleted = true; } diff --git a/libs/api/tracker/domain/src/lib/read-models/issue.read-model.ts b/libs/api/tracker/domain/src/lib/read-models/issue.read-model.ts index 0c564a4..6425de6 100644 --- a/libs/api/tracker/domain/src/lib/read-models/issue.read-model.ts +++ b/libs/api/tracker/domain/src/lib/read-models/issue.read-model.ts @@ -87,6 +87,8 @@ export class IssueReadModel { checklist!: ChecklistItemReadModel[]; /** Commits, branches and pull requests that mention it. */ devLinks!: DevLink[]; + /** In the trash since then (null: not in it). */ + trashedAt!: string | null; commentCount!: number; createdAt!: string; updatedAt!: string; diff --git a/libs/api/tracker/infrastructure/src/lib/mongo/find.repositories.ts b/libs/api/tracker/infrastructure/src/lib/mongo/find.repositories.ts index 4db8de0..f5241c9 100644 --- a/libs/api/tracker/infrastructure/src/lib/mongo/find.repositories.ts +++ b/libs/api/tracker/infrastructure/src/lib/mongo/find.repositories.ts @@ -156,6 +156,7 @@ export class MongoFindIssuesRepository extends FindIssuesRepository { if (filter.projectIds.length === 0) return { items: [], total: 0 }; const query: Record = { projectId: { $in: filter.projectIds }, + trashedAt: null, }; if (filter.assigneeId) query['assignee.id'] = filter.assigneeId; if (filter.statuses?.length) query['status'] = { $in: filter.statuses }; @@ -195,7 +196,7 @@ export class MongoFindIssuesRepository extends FindIssuesRepository { byArea: { _id: string | null; count: number }[]; byAssignee: { _id: UserRefReadModel | null; count: number }[]; }>([ - { $match: { projectId } }, + { $match: { projectId, trashedAt: null } }, { $facet: { total: [{ $count: 'count' }], @@ -233,7 +234,11 @@ export class MongoFindIssuesRepository extends FindIssuesRepository { async findOpenAssignedTo(userId: string): Promise { const docs = await this.issues - .find({ 'assignee.id': userId, statusCategory: { $nin: FINISHED } }) + .find({ + 'assignee.id': userId, + statusCategory: { $nin: FINISHED }, + trashedAt: null, + }) .lean(); return docs.map(issueFromDoc); } @@ -242,6 +247,7 @@ export class MongoFindIssuesRepository extends FindIssuesRepository { const docs = await this.issues .find({ dueDate: { $ne: null, $lte: `${day}\uffff` }, + trashedAt: null, statusCategory: { $nin: FINISHED }, $or: [{ assignee: { $ne: null } }, { 'watchers.0': { $exists: true } }], }) @@ -251,7 +257,26 @@ export class MongoFindIssuesRepository extends FindIssuesRepository { } async findByProject(projectId: string): Promise { - const docs = await this.issues.find({ projectId }).lean(); + const docs = await this.issues + .find({ projectId, trashedAt: null }) + .lean(); + return docs.map(issueFromDoc); + } + + async findTrashed(projectId: string): Promise { + const docs = await this.issues + .find({ projectId, trashedAt: { $ne: null } }) + .sort({ trashedAt: -1 }) + .limit(500) + .lean(); + return docs.map(issueFromDoc); + } + + async findTrashedBefore(before: string): Promise { + const docs = await this.issues + .find({ trashedAt: { $ne: null, $lt: before } }) + .limit(1000) + .lean(); return docs.map(issueFromDoc); } @@ -265,7 +290,13 @@ export class MongoFindIssuesRepository extends FindIssuesRepository { storyPoints: number; finishedStoryPoints: number; }>([ - { $match: { projectId, 'parent.id': { $type: 'string' } } }, + { + $match: { + projectId, + 'parent.id': { $type: 'string' }, + trashedAt: null, + }, + }, { $group: { _id: '$parent.id', @@ -280,7 +311,11 @@ export class MongoFindIssuesRepository extends FindIssuesRepository { const progress = new Map(rows.map((row) => [row._id, row])); // Only the top of each tree: a ticket with subtasks under an epic is not one. const docs = await this.issues - .find({ _id: { $in: [...progress.keys()] }, 'parent.id': null }) + .find({ + _id: { $in: [...progress.keys()] }, + 'parent.id': null, + trashedAt: null, + }) .sort({ number: 1 }) .lean(); return docs.map((doc) => { @@ -311,7 +346,7 @@ export class MongoFindIssuesRepository extends FindIssuesRepository { storyPoints: number; finishedStoryPoints: number; }>([ - { $match: { 'sprint.id': sprintId } }, + { $match: { 'sprint.id': sprintId, trashedAt: null } }, { $group: { _id: null, @@ -338,7 +373,7 @@ export class MongoFindIssuesRepository extends FindIssuesRepository { _id: { status: string; category: StatusCategory }; count: number; }>([ - { $match: { 'release.id': releaseId } }, + { $match: { 'release.id': releaseId, trashedAt: null } }, { $group: { _id: { status: '$status', category: '$statusCategory' }, @@ -363,7 +398,11 @@ export class MongoFindIssuesRepository extends FindIssuesRepository { } function toMongoFilter(filter: IssueFilter): FilterQuery { - const query: FilterQuery = { projectId: filter.projectId }; + // Tickets in the trash are only listed by the trash itself. + const query: FilterQuery = { + projectId: filter.projectId, + trashedAt: null, + }; if (filter.statuses?.length) query.status = { $in: filter.statuses }; if (filter.categories?.length) query['statusCategory'] = { $in: filter.categories }; 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 80cd2cb..ab51ac1 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 @@ -133,6 +133,7 @@ IssueSchema.index({ 'parent.id': 1 }); IssueSchema.index({ projectId: 1, rank: 1 }); IssueSchema.index({ 'release.id': 1 }); IssueSchema.index({ 'sprint.id': 1 }); +IssueSchema.index({ trashedAt: 1 }, { sparse: true }); IssueSchema.index({ title: 'text', description: 'text', key: 'text' }); export const CommentSchema = new Schema( @@ -285,6 +286,7 @@ export function issueFromDoc(doc: IssueDoc): IssueReadModel { startDate: rest.startDate ?? null, sprint: rest.sprint?.id ? rest.sprint : null, devLinks: rest.devLinks ?? [], + trashedAt: rest.trashedAt ?? null, checklist: rest.checklist ?? [], }; } diff --git a/libs/api/tracker/infrastructure/src/lib/notifications/trash.scheduler.ts b/libs/api/tracker/infrastructure/src/lib/notifications/trash.scheduler.ts new file mode 100644 index 0000000..86dabe0 --- /dev/null +++ b/libs/api/tracker/infrastructure/src/lib/notifications/trash.scheduler.ts @@ -0,0 +1,60 @@ +import { + Injectable, + Logger, + OnApplicationBootstrap, + OnApplicationShutdown, +} from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { CommandBus } from '@nestjs/cqrs'; +import { + EmptyTrashCommand, + TRASH_DAYS, +} from '@task-stream/api/tracker/application'; + +const HOUR = 3_600_000; + +/** + * Deletes for good what has been in the trash longer than TRASH_DAYS (30 + * by default), a few minutes after start and then every six hours. + */ +@Injectable() +export class TrashScheduler + implements OnApplicationBootstrap, OnApplicationShutdown +{ + private readonly logger = new Logger(TrashScheduler.name); + private timers: ReturnType[] = []; + + constructor( + private readonly commandBus: CommandBus, + private readonly config: ConfigService, + ) {} + + onApplicationBootstrap(): void { + const first = setTimeout(() => void this.run(), 5 * 60_000); + const every = setInterval(() => void this.run(), 6 * HOUR); + first.unref?.(); + every.unref?.(); + this.timers = [first, every]; + } + + onApplicationShutdown(): void { + this.timers.forEach((timer) => clearTimeout(timer)); + } + + private async run(): Promise { + const days = + Number(this.config.get('TRASH_DAYS') ?? TRASH_DAYS) || TRASH_DAYS; + const before = new Date(Date.now() - days * 24 * HOUR).toISOString(); + try { + const deleted: number = await this.commandBus.execute( + new EmptyTrashCommand(before), + ); + if (deleted) + this.logger.log(`Deleted ${deleted} ticket(s) from the trash`); + } catch (error) { + this.logger.warn( + `Emptying the trash failed: ${(error as Error).message}`, + ); + } + } +} 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 1fa7367..e794cf2 100644 --- a/libs/api/tracker/infrastructure/src/lib/tracker-infrastructure.module.ts +++ b/libs/api/tracker/infrastructure/src/lib/tracker-infrastructure.module.ts @@ -54,6 +54,7 @@ import { import { StatusCategoryBackfill } from './mongo/status-category.backfill'; import { RankBackfill } from './mongo/rank.backfill'; import { ReminderScheduler } from './notifications/reminder.scheduler'; +import { TrashScheduler } from './notifications/trash.scheduler'; import { LOOKUP_ENTITIES } from './postgres/lookup.entities'; import { PostgresIssueNumbersRepository, @@ -159,6 +160,7 @@ const PORTS = [ StatusCategoryBackfill, RankBackfill, ReminderScheduler, + TrashScheduler, ], exports: PORTS.map((port) => port.provide), }) 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 c462809..bbe98a4 100644 --- a/libs/api/tracker/presenters/src/lib/graphql/issue.resolver.ts +++ b/libs/api/tracker/presenters/src/lib/graphql/issue.resolver.ts @@ -290,6 +290,36 @@ export class IssueResolver { ); } + @Mutation(() => CommandResultGql, { + description: + 'To the trash (whoever opened it, or an owner/admin): hidden everywhere, deleted for good after 30 days', + }) + trashIssue( + @CurrentUser() me: User, + @Args('issueId', { type: () => ID }) issueId: string, + ) { + return this.issuesService.trash(me.id, issueId); + } + + @Mutation(() => CommandResultGql, { description: 'Back from the trash' }) + restoreIssue( + @CurrentUser() me: User, + @Args('issueId', { type: () => ID }) issueId: string, + ) { + return this.issuesService.restore(me.id, issueId); + } + + @OptionalAuth() + @Query(() => [IssueGql], { + description: "A project's tickets in the trash, newest first", + }) + trash( + @CurrentUser() me: User | undefined, + @Args('projectId', { type: () => ID }) projectId: string, + ) { + return this.issuesService.trashed(viewerOf(me), projectId); + } + @Mutation(() => BulkResultGql, { description: 'The same change on up to 100 tickets; each goes through the normal rules and may refuse on its own', diff --git a/libs/api/tracker/presenters/src/lib/graphql/types.ts b/libs/api/tracker/presenters/src/lib/graphql/types.ts index 113c9aa..ddf753e 100644 --- a/libs/api/tracker/presenters/src/lib/graphql/types.ts +++ b/libs/api/tracker/presenters/src/lib/graphql/types.ts @@ -342,6 +342,11 @@ export class IssueGql { }) rank!: string; @Field(() => [ChecklistItemGql]) checklist!: ChecklistItemGql[]; + @Field(() => String, { + nullable: true, + description: 'In the trash since then; null when it is not', + }) + trashedAt!: string | null; @Field(() => [DevLinkGql], { description: 'Commits, branches and pull requests that mention it', }) 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 166dc99..05c8b04 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 @@ -309,6 +309,13 @@ export class IssueDto { }) rank!: string; @ApiProperty({ type: [ChecklistItemDto] }) checklist!: ChecklistItemDto[]; + @ApiProperty({ + type: String, + nullable: true, + format: 'date-time', + description: 'In the trash since then; null when it is not', + }) + trashedAt!: string | null; @ApiProperty({ type: [DevLinkDto], description: 'Commits, branches and pull requests that mention it', 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 00f8929..d2db009 100644 --- a/libs/api/tracker/presenters/src/lib/http/issues.controller.ts +++ b/libs/api/tracker/presenters/src/lib/http/issues.controller.ts @@ -311,6 +311,47 @@ export class IssuesController { ); } + @Delete('issues/:issue') + @ISSUE + @ApiOperation({ + summary: 'Move the ticket to the trash', + description: + 'Whoever opened it, or an owner/admin. Hidden everywhere; restore it within 30 days or it is deleted for good.', + }) + @ApiOkResponse({ type: CommandResultDto }) + @ApiCommandErrors() + async trash(@CurrentUser() me: User, @Param('issue') issue: string) { + return this.issues.trash(me.id, await this.refs.issueId(me.id, issue)); + } + + @Post('issues/:issue/restore') + @HttpCode(200) + @ISSUE + @ApiOperation({ summary: 'Bring the ticket back from the trash' }) + @ApiOkResponse({ type: CommandResultDto }) + @ApiCommandErrors() + async restore(@CurrentUser() me: User, @Param('issue') issue: string) { + return this.issues.restore(me.id, await this.refs.issueId(me.id, issue)); + } + + @Get('projects/:project/trash') + @ApiOptionalPireonAuth() + @PROJECT + @ApiOperation({ summary: "The project's tickets in the trash, newest first" }) + @ApiOkResponse({ type: [IssueDto] }) + @ApiReadErrors() + async trashed( + @CurrentUser() me: User | undefined, + @Param('project') project: string, + ) { + return this.refs.issueDtos( + await this.issues.trashed( + viewerOf(me), + await this.refs.projectId(viewerOf(me), project), + ), + ); + } + @Post('issues/bulk') @HttpCode(200) @ApiOperation({ 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 47ab353..e65183f 100644 --- a/libs/web/tracker/application/src/lib/events/issue.events.ts +++ b/libs/web/tracker/application/src/lib/events/issue.events.ts @@ -41,6 +41,7 @@ export const issuePageEvents = eventGroup({ files?: File[]; }>(), watchRequested: type<{ issueId: string; watching: 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 }>(), subtaskCreateRequested: type<{ 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 7565362..5aa9762 100644 --- a/libs/web/tracker/application/src/lib/facades/issue.facade.ts +++ b/libs/web/tracker/application/src/lib/facades/issue.facade.ts @@ -99,6 +99,13 @@ export class IssueFacade { ); } + /** To the trash (true) or back from it (false). */ + setTrashed(trashed: boolean): void { + this.withIssue((issueId) => + this.dispatch.trashRequested({ issueId, trashed }), + ); + } + /** Follow the ticket (or stop): be told when it moves or gets a comment. */ setWatching(watching: boolean): void { this.withIssue((issueId) => 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 34d0d08..73a5fb9 100644 --- a/libs/web/tracker/application/src/lib/stores/issue.store.ts +++ b/libs/web/tracker/application/src/lib/stores/issue.store.ts @@ -280,6 +280,22 @@ export const IssueStore = signalStore( ), ), ), + events + .on(issuePageEvents.trashRequested) + .pipe( + mergeMap(({ payload }) => + runCommand( + payload.trashed + ? issues.trash(payload.issueId) + : issues.restore(payload.issueId), + payload.trashed + ? 'Ticket en la papelera' + : 'Ticket recuperado', + 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 7d5064f..52a3966 100644 --- a/libs/web/tracker/domain/src/lib/models/models.ts +++ b/libs/web/tracker/domain/src/lib/models/models.ts @@ -119,6 +119,8 @@ export interface IssueSummary { /** Planned start and end (`YYYY-MM-DD`), for the roadmap. */ startDate: string | null; dueDate: string | null; + /** In the trash since then (null: not in it). */ + trashedAt: string | null; commentCount: number; updatedAt: string; version: number; diff --git a/libs/web/tracker/domain/src/lib/ports/gateways.ts b/libs/web/tracker/domain/src/lib/ports/gateways.ts index 52b3e67..5b3f2f3 100644 --- a/libs/web/tracker/domain/src/lib/ports/gateways.ts +++ b/libs/web/tracker/domain/src/lib/ports/gateways.ts @@ -115,6 +115,11 @@ export abstract class IssuesGateway { parentId: string | null, ): Observable; abstract epics(projectId: string): Observable; + /** To the trash (whoever opened it, or a manager). */ + abstract trash(issueId: string): Observable; + abstract restore(issueId: string): Observable; + /** The project's tickets in the trash, newest first. */ + abstract trashed(projectId: string): Observable; /** The same change on several tickets; each may refuse on its own. */ abstract bulkUpdate( issueIds: 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 6316158..e8aa18b 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 @@ -32,6 +32,9 @@ import { MyIssuesDocument, RankIssueDocument, RemoveChecklistItemDocument, + RestoreIssueDocument, + TrashDocument, + TrashIssueDocument, SearchIssuesDocument, SetIssueParentDocument, SetIssueReleaseDocument, @@ -180,6 +183,24 @@ export class GraphqlIssuesGateway extends IssuesGateway { ); } + trash(issueId: string): Observable { + return this.gql + .mutate(TrashIssueDocument, { issueId }) + .pipe(map((d) => toCommandResult(d.trashIssue))); + } + + restore(issueId: string): Observable { + return this.gql + .mutate(RestoreIssueDocument, { issueId }) + .pipe(map((d) => toCommandResult(d.restoreIssue))); + } + + trashed(projectId: string): Observable { + return this.gql + .query(TrashDocument, { projectId }) + .pipe(map((d) => d.trash.map(toIssueSummary))); + } + bulkUpdate(issueIds: string[], change: BulkChange): Observable { return this.gql .mutate(BulkUpdateIssuesDocument, { diff --git a/libs/web/tracker/infrastructure/src/lib/graphql/fragments.graphql b/libs/web/tracker/infrastructure/src/lib/graphql/fragments.graphql index dfb5969..2f09486 100644 --- a/libs/web/tracker/infrastructure/src/lib/graphql/fragments.graphql +++ b/libs/web/tracker/infrastructure/src/lib/graphql/fragments.graphql @@ -87,6 +87,7 @@ fragment IssueSummaryFields on Issue { storyPoints startDate dueDate + trashedAt } fragment IssueFields on Issue { diff --git a/libs/web/tracker/infrastructure/src/lib/graphql/generated.ts b/libs/web/tracker/infrastructure/src/lib/graphql/generated.ts index 5b6c2a6..dce6e2a 100644 --- a/libs/web/tracker/infrastructure/src/lib/graphql/generated.ts +++ b/libs/web/tracker/infrastructure/src/lib/graphql/generated.ts @@ -481,6 +481,7 @@ export type IssueSummaryFieldsFragment = { 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; @@ -509,6 +510,7 @@ export type IssueFieldsFragment = { storyPoints: number | null; startDate: string | null; dueDate: string | null; + trashedAt: string | null; reporter: { id: string; name: string }; links: Array<{ type: IssueLinkType; @@ -652,6 +654,7 @@ export type BoardQuery = { 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; @@ -688,6 +691,7 @@ export type IssueQuery = { storyPoints: number | null; startDate: string | null; dueDate: string | null; + trashedAt: string | null; reporter: { id: string; name: string }; links: Array<{ type: IssueLinkType; @@ -747,6 +751,7 @@ export type SearchIssuesQuery = { 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; @@ -903,6 +908,7 @@ export type MyIssuesQuery = { 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; @@ -935,6 +941,7 @@ export type IssueChildrenQuery = { 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; @@ -967,6 +974,7 @@ export type EpicsQuery = { 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; @@ -1041,6 +1049,7 @@ export type BacklogQuery = { 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; @@ -1068,6 +1077,53 @@ export type BulkUpdateIssuesMutation = { }; }; +export type TrashIssueMutationVariables = Exact<{ + issueId: string; +}>; + +export type TrashIssueMutation = { + trashIssue: { id: string; key: string | null; version: number }; +}; + +export type RestoreIssueMutationVariables = Exact<{ + issueId: string; +}>; + +export type RestoreIssueMutation = { + restoreIssue: { id: string; key: string | null; version: number }; +}; + +export type TrashQueryVariables = Exact<{ + projectId: string; +}>; + +export type TrashQuery = { + trash: Array<{ + id: string; + projectId: string; + key: string; + type: string; + area: string | null; + title: string; + status: string; + statusCategory: StatusCategory; + priority: IssuePriority; + labels: Array; + commentCount: number; + updatedAt: string; + version: number; + 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; + }>; +}; + export type NotificationFieldsFragment = { id: string; kind: NotificationKind; @@ -1714,6 +1770,7 @@ export type IssueChangedSubscription = { 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; @@ -2323,6 +2380,7 @@ export const IssueSummaryFieldsFragmentDoc = { { 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: 'trashedAt' } }, ], }, }, @@ -2555,6 +2613,7 @@ export const IssueFieldsFragmentDoc = { { 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: 'trashedAt' } }, ], }, }, @@ -4010,6 +4069,7 @@ export const BoardDocument = { { 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: 'trashedAt' } }, ], }, }, @@ -4156,6 +4216,7 @@ export const IssueDocument = { { 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: 'trashedAt' } }, ], }, }, @@ -4436,6 +4497,7 @@ export const SearchIssuesDocument = { { 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: 'trashedAt' } }, ], }, }, @@ -5682,6 +5744,7 @@ export const MyIssuesDocument = { { 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: 'trashedAt' } }, ], }, }, @@ -5842,6 +5905,7 @@ export const IssueChildrenDocument = { { 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: 'trashedAt' } }, ], }, }, @@ -6020,6 +6084,7 @@ export const EpicsDocument = { { 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: 'trashedAt' } }, ], }, }, @@ -6502,6 +6567,7 @@ export const BacklogDocument = { { 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: 'trashedAt' } }, ], }, }, @@ -6646,6 +6712,287 @@ export const BulkUpdateIssuesDocument = { BulkUpdateIssuesMutation, BulkUpdateIssuesMutationVariables >; +export const TrashIssueDocument = { + kind: 'Document', + definitions: [ + { + kind: 'OperationDefinition', + operation: 'mutation', + name: { kind: 'Name', value: 'TrashIssue' }, + 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: 'trashIssue' }, + arguments: [ + { + kind: 'Argument', + name: { kind: 'Name', value: 'issueId' }, + value: { + kind: 'Variable', + name: { kind: 'Name', value: 'issueId' }, + }, + }, + ], + selectionSet: { + kind: 'SelectionSet', + selections: [ + { + kind: 'FragmentSpread', + name: { kind: 'Name', value: 'CommandResultFields' }, + }, + ], + }, + }, + ], + }, + }, + { + kind: 'FragmentDefinition', + name: { kind: 'Name', value: 'CommandResultFields' }, + typeCondition: { + kind: 'NamedType', + name: { kind: 'Name', value: 'CommandResult' }, + }, + selectionSet: { + kind: 'SelectionSet', + selections: [ + { kind: 'Field', name: { kind: 'Name', value: 'id' } }, + { kind: 'Field', name: { kind: 'Name', value: 'key' } }, + { kind: 'Field', name: { kind: 'Name', value: 'version' } }, + ], + }, + }, + ], +} as unknown as DocumentNode; +export const RestoreIssueDocument = { + kind: 'Document', + definitions: [ + { + kind: 'OperationDefinition', + operation: 'mutation', + name: { kind: 'Name', value: 'RestoreIssue' }, + 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: 'restoreIssue' }, + arguments: [ + { + kind: 'Argument', + name: { kind: 'Name', value: 'issueId' }, + value: { + kind: 'Variable', + name: { kind: 'Name', value: 'issueId' }, + }, + }, + ], + selectionSet: { + kind: 'SelectionSet', + selections: [ + { + kind: 'FragmentSpread', + name: { kind: 'Name', value: 'CommandResultFields' }, + }, + ], + }, + }, + ], + }, + }, + { + kind: 'FragmentDefinition', + name: { kind: 'Name', value: 'CommandResultFields' }, + typeCondition: { + kind: 'NamedType', + name: { kind: 'Name', value: 'CommandResult' }, + }, + selectionSet: { + kind: 'SelectionSet', + selections: [ + { kind: 'Field', name: { kind: 'Name', value: 'id' } }, + { kind: 'Field', name: { kind: 'Name', value: 'key' } }, + { kind: 'Field', name: { kind: 'Name', value: 'version' } }, + ], + }, + }, + ], +} as unknown as DocumentNode< + RestoreIssueMutation, + RestoreIssueMutationVariables +>; +export const TrashDocument = { + kind: 'Document', + definitions: [ + { + kind: 'OperationDefinition', + operation: 'query', + name: { kind: 'Name', value: 'Trash' }, + variableDefinitions: [ + { + kind: 'VariableDefinition', + variable: { + kind: 'Variable', + name: { kind: 'Name', value: 'projectId' }, + }, + type: { + kind: 'NonNullType', + type: { kind: 'NamedType', name: { kind: 'Name', value: 'ID' } }, + }, + }, + ], + selectionSet: { + kind: 'SelectionSet', + selections: [ + { + kind: 'Field', + name: { kind: 'Name', value: 'trash' }, + arguments: [ + { + kind: 'Argument', + name: { kind: 'Name', value: 'projectId' }, + value: { + kind: 'Variable', + name: { kind: 'Name', value: 'projectId' }, + }, + }, + ], + 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: '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: '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: 'trashedAt' } }, + ], + }, + }, + ], +} as unknown as DocumentNode; export const MyNotificationsDocument = { kind: 'Document', definitions: [ @@ -10421,6 +10768,7 @@ export const IssueChangedDocument = { { 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: 'trashedAt' } }, ], }, }, diff --git a/libs/web/tracker/infrastructure/src/lib/graphql/issues.graphql b/libs/web/tracker/infrastructure/src/lib/graphql/issues.graphql index acda925..8d73545 100644 --- a/libs/web/tracker/infrastructure/src/lib/graphql/issues.graphql +++ b/libs/web/tracker/infrastructure/src/lib/graphql/issues.graphql @@ -221,3 +221,21 @@ mutation BulkUpdateIssues($input: BulkUpdateIssuesInput!) { } } } + +mutation TrashIssue($issueId: ID!) { + trashIssue(issueId: $issueId) { + ...CommandResultFields + } +} + +mutation RestoreIssue($issueId: ID!) { + restoreIssue(issueId: $issueId) { + ...CommandResultFields + } +} + +query Trash($projectId: ID!) { + trash(projectId: $projectId) { + ...IssueSummaryFields + } +} diff --git a/libs/web/tracker/presenters/src/lib/pages/backlog.page.ts b/libs/web/tracker/presenters/src/lib/pages/backlog.page.ts index d4bfb63..4fb8f84 100644 --- a/libs/web/tracker/presenters/src/lib/pages/backlog.page.ts +++ b/libs/web/tracker/presenters/src/lib/pages/backlog.page.ts @@ -223,6 +223,9 @@ const NOBODY = '__none__'; description="Los tickets nuevos aparecen aquí, al final, hasta que alguien los coge." /> } +

+ Papelera +

`, styles: ` .ts-backlog { @@ -275,6 +278,11 @@ const NOBODY = '__none__'; align-items: center; font-size: 0.875rem; } + .ts-backlog__trash { + margin: 1rem 0 0; + font-size: 0.875rem; + text-align: right; + } .ts-backlog__pos { color: var(--ts-muted); font-size: 0.75rem; 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 82792f8..e3982e8 100644 --- a/libs/web/tracker/presenters/src/lib/pages/issue.page.scss +++ b/libs/web/tracker/presenters/src/lib/pages/issue.page.scss @@ -520,3 +520,14 @@ a.ts-key { .ts-dev__when { font-size: 0.8125rem; } + +.ts-trashed { + display: flex; + flex-wrap: wrap; + gap: 0.75rem; + align-items: center; + margin-bottom: 1rem; + padding: 0.75rem 1rem; + border-radius: 0.5rem; + background: color-mix(in srgb, var(--ts-c-red, #dc2626) 12%, transparent); +} 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 9d1c6a1..cd608de 100644 --- a/libs/web/tracker/presenters/src/lib/pages/issue.page.ts +++ b/libs/web/tracker/presenters/src/lib/pages/issue.page.ts @@ -94,6 +94,20 @@ const MAX_FILE_BYTES = 25 * 1024 * 1024; ← Tablero @if (facade.issue(); as issue) { + @if (issue.trashedAt) { +
+ En la papelera desde {{ ago(issue.trashedAt) }}: no aparece en el + tablero ni en las búsquedas, y se borrará del todo a los 30 días. + @if (canTrash(issue)) { + + } +
+ }
@@ -698,6 +712,15 @@ const MAX_FILE_BYTES = 25 * 1024 * 1024; Área + @if (!issue.trashedAt && canTrash(issue)) { + + }
Reportado por
{{ issue.reporter.name }}
@@ -1122,6 +1145,23 @@ export class IssuePage { } /** A screenshot pasted anywhere on the ticket is attached to it. */ + /** Whoever opened it, or whoever manages the project. */ + protected canTrash(issue: { reporter: { id: string } }): boolean { + return ( + this.project.canContribute() && + (issue.reporter.id === this.facade.me() || this.project.canManage()) + ); + } + + protected trash(): void { + if ( + confirm( + '¿Mover el ticket a la papelera? Podrás recuperarlo durante 30 días.', + ) + ) + this.facade.setTrashed(true); + } + /** `e`: edit the ticket (not while typing, nor with a drawer open). */ @HostListener('document:keydown', ['$event']) protected onKey(event: KeyboardEvent): void { diff --git a/libs/web/tracker/presenters/src/lib/pages/trash.page.ts b/libs/web/tracker/presenters/src/lib/pages/trash.page.ts new file mode 100644 index 0000000..eb33911 --- /dev/null +++ b/libs/web/tracker/presenters/src/lib/pages/trash.page.ts @@ -0,0 +1,130 @@ +import { + ChangeDetectionStrategy, + Component, + DestroyRef, + effect, + inject, + signal, + untracked, +} from '@angular/core'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { RouterLink } from '@angular/router'; +import { PiActionButtonComponent, PiEmptyStateComponent } from '@pi/ui'; +import { ToastService } from '@task-stream/web/shared/ui'; +import { ProjectFacade } from '@task-stream/web/tracker/application'; +import { IssueSummary, IssuesGateway } from '@task-stream/web/tracker/domain'; +import { timeAgo } from '../ui/copy'; + +/** /p/:key/trash — tickets deleted in the last 30 days, to bring back. */ +@Component({ + selector: 'ts-trash-page', + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [RouterLink, PiActionButtonComponent, PiEmptyStateComponent], + template: ` +
+

+ Tickets borrados en los últimos 30 días. Después se borran del todo, con + sus comentarios. +

+
+ @if (issues().length) { +
    + @for (issue of issues(); track issue.id) { +
  • + {{ + issue.key + }} + {{ issue.title }} + {{ + issue.trashedAt ? ago(issue.trashedAt) : '' + }} + @if (project.canContribute()) { + + } +
  • + } +
+ } @else if (loaded()) { + + } + `, + styles: ` + .ts-trash { + margin: 0; + padding: 0.25rem 0; + list-style: none; + } + .ts-trash__row { + display: grid; + grid-template-columns: auto minmax(0, 1fr) auto auto; + gap: 0.75rem; + align-items: center; + padding: 0.5rem 0.875rem; + border-top: 1px solid var(--ts-line); + &:first-child { + border-top: 0; + } + } + .ts-trash__title { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + `, +}) +export class TrashPage { + protected readonly project = inject(ProjectFacade); + private readonly gateway = inject(IssuesGateway); + private readonly toast = inject(ToastService); + private readonly destroyRef = inject(DestroyRef); + protected readonly issues = signal([]); + protected readonly loaded = signal(false); + protected readonly ago = timeAgo; + + constructor() { + effect(() => { + const projectId = this.project.project()?.id; + if (projectId) untracked(() => this.reload(projectId)); + }); + } + + protected restore(issue: IssueSummary): void { + this.gateway + .restore(issue.id) + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe({ + next: () => { + this.toast.success(`${issue.key} recuperado`); + this.issues.update((list) => list.filter((i) => i.id !== issue.id)); + }, + error: (error) => + this.toast.error( + (error as { message?: string })?.message?.split('\n')[0] || + 'No se pudo recuperar', + ), + }); + } + + private reload(projectId: string): void { + this.gateway + .trashed(projectId) + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe({ + next: (issues) => { + this.issues.set(issues); + this.loaded.set(true); + }, + error: () => this.loaded.set(true), + }); + } +} diff --git a/libs/web/tracker/presenters/src/lib/tracker.routes.ts b/libs/web/tracker/presenters/src/lib/tracker.routes.ts index dd28b2f..2ae1475 100644 --- a/libs/web/tracker/presenters/src/lib/tracker.routes.ts +++ b/libs/web/tracker/presenters/src/lib/tracker.routes.ts @@ -62,6 +62,12 @@ export const trackerRoutes: Routes = [ loadComponent: () => import('./pages/epics.page').then((m) => m.EpicsPage), }, + { + path: 'trash', + title: 'Papelera · TaskStream', + loadComponent: () => + import('./pages/trash.page').then((m) => m.TrashPage), + }, { path: 'releases', title: 'Releases · TaskStream',